mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-09 07:17:07 -05:00
feat: custom nodes
This commit is contained in:
11
package.json
11
package.json
@@ -23,11 +23,15 @@
|
||||
"@heroui/system": "2.4.6",
|
||||
"@heroui/theme": "2.4.5",
|
||||
"@internationalized/date": "^3.7.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/themes": "^3.2.0",
|
||||
"@react-aria/ssr": "3.9.7",
|
||||
"@react-aria/visually-hidden": "3.8.18",
|
||||
"@supabase-cache-helpers/postgrest-swr": "^1.10.6",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"@tailwindcss/cli": "^4.0.3",
|
||||
"@tailwindcss/postcss": "^4.0.3",
|
||||
"@xyflow/react": "^12.4.2",
|
||||
"clsx": "2.1.1",
|
||||
"framer-motion": "11.13.1",
|
||||
@@ -35,9 +39,12 @@
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next-themes": "^0.4.4",
|
||||
"postcss": "^8.5.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"swr": "^2.3.0"
|
||||
"swr": "^2.3.0",
|
||||
"tailwindcss": "^4.0.3",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "15.0.4",
|
||||
@@ -58,10 +65,8 @@
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-unused-imports": "4.1.4",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.3.3",
|
||||
"tailwind-variants": "0.1.20",
|
||||
"tailwindcss": "3.4.16",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
6
postcss.config.mjs
Normal file
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createClient } from '@/src/lib/supabase/server'
|
||||
|
||||
export async function signInWithGithub() {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"use server"
|
||||
|
||||
import { Comment } from "@/types/kanban";
|
||||
import { createClient } from "../lib/supabase/server"
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
||||
export async function submitComment(comment: Partial<Comment>): Promise<string | null> {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser()
|
||||
|
||||
if (userError || !user) redirect("/error")
|
||||
|
||||
const { error } = await supabase
|
||||
.from("comments")
|
||||
.insert([{ ...comment, user_id: user.id }]);
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
return error
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use server"
|
||||
import { Project } from "@/types/project"
|
||||
import { createClient } from "../lib/supabase/server"
|
||||
import { redirect } from "next/navigation"
|
||||
import { Organization } from "@/types/organization"
|
||||
import { Task } from "@/types/kanban"
|
||||
|
||||
export async function getProject(project_id: string): Promise<Project> {
|
||||
const supabase = await createClient()
|
||||
let { data: project, error } = await supabase
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
org:organizations(name, id),
|
||||
members:project_members(
|
||||
users(id, user_name, avatar_url)
|
||||
)
|
||||
`)
|
||||
.eq('id', project_id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return redirect("/404")
|
||||
}
|
||||
project.members = project.members.map(({ users }: any) => users)
|
||||
|
||||
|
||||
return project as Project
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
const supabase = await createClient()
|
||||
let { data: projects, error } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, description')
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
return projects as Project[]
|
||||
}
|
||||
|
||||
export async function getTasks(project_id: string): Promise<Partial<Task>[]> {
|
||||
const supabase = await createClient()
|
||||
let tasks = await supabase
|
||||
.from('tasks')
|
||||
.select('id, title, description, priority,column_id, column:columns!column_id(id, name), project:projects!project_id(id, name), comments(id), user:users!user_id(id, user_name, email, avatar_url), tags:task_tags(tags(id, name, color))')
|
||||
.eq("project_id", project_id)
|
||||
.then(({ data, error }) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
//@ts-ignore
|
||||
return data.map((task => ({ ...task, status: task.column.name })))
|
||||
})
|
||||
//@ts-ignore
|
||||
return tasks as Partial<Task>[]
|
||||
}
|
||||
|
||||
export async function getOrgsAndProjects(): Promise<Organization[]> {
|
||||
const supabase = await createClient()
|
||||
let { data: orgs, error } = await supabase
|
||||
.from('organizations')
|
||||
.select('id, created_at, name, description, projects!inner(id, name, description, columns(id, tasks(id)))')
|
||||
.limit(4, { foreignTable: 'projects.columns' })
|
||||
.limit(6, { foreignTable: 'projects.columns.tasks' })
|
||||
.order('created_at', { ascending: true })
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
return orgs as Organization[]
|
||||
}
|
||||
|
||||
export async function createProject(values: { name: string, description: string, org_id: string }): Promise<Partial<Project>> {
|
||||
const supabase = await createClient()
|
||||
let { data: project, error } = await supabase
|
||||
.from('projects')
|
||||
.insert({ name: values.name, description: values.description, org_id: values.org_id }).select("id").single()
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (!project) redirect("/error")
|
||||
return project
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Navbar } from '@/src/components/navbar';
|
||||
import React from 'react'
|
||||
|
||||
const DashboardLayout = ({
|
||||
@@ -8,7 +7,6 @@ const DashboardLayout = ({
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="container mx-auto max-w-7xl pt-16 px-6 flex-grow">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,10 +7,11 @@ const DashboardPage = async ({
|
||||
params: Promise<{ investigation_id: string }>
|
||||
}) => {
|
||||
const { investigation_id } = await (params)
|
||||
console.log(investigation_id)
|
||||
const { nodes, edges } = await getInvestigationData(investigation_id)
|
||||
return (
|
||||
<div>
|
||||
<InvestigationGraph initialNodes={nodes} initialEdges={edges} />
|
||||
<InvestigationGraph initialNodes={nodes} initialEdges={edges} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import { Link } from "@heroui/link";
|
||||
import clsx from "clsx";
|
||||
import "@/styles/globals.css"
|
||||
import { Providers } from "./providers";
|
||||
|
||||
import "@radix-ui/themes/styles.css";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { fontSans } from "@/config/fonts";
|
||||
import { Theme } from "@radix-ui/themes";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -41,9 +41,11 @@ export default function RootLayout({
|
||||
)}
|
||||
>
|
||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
<Theme>
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
</Theme>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,55 +1,11 @@
|
||||
import { Link } from "@heroui/link";
|
||||
import { Snippet } from "@heroui/snippet";
|
||||
import { Code } from "@heroui/code";
|
||||
import { button as buttonStyles } from "@heroui/theme";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { title, subtitle } from "@/src/components/primitives";
|
||||
import { GithubIcon } from "@/src/components/icons";
|
||||
import { Button } from "@radix-ui/themes";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
||||
<div className="inline-block max-w-xl text-center justify-center">
|
||||
<span className={title()}>Make </span>
|
||||
<span className={title({ color: "violet" })}>beautiful </span>
|
||||
<br />
|
||||
<span className={title()}>
|
||||
websites regardless of your design experience.
|
||||
</span>
|
||||
<div className={subtitle({ class: "mt-4" })}>
|
||||
Beautiful, fast and modern React UI library.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
isExternal
|
||||
className={buttonStyles({
|
||||
color: "primary",
|
||||
radius: "full",
|
||||
variant: "shadow",
|
||||
})}
|
||||
href={siteConfig.links.docs}
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
isExternal
|
||||
className={buttonStyles({ variant: "bordered", radius: "full" })}
|
||||
href={siteConfig.links.github}
|
||||
>
|
||||
<GithubIcon size={20} />
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Snippet hideCopyButton hideSymbol variant="bordered">
|
||||
<span>
|
||||
Get started by editing <Code color="primary">app/page.tsx</Code>
|
||||
</span>
|
||||
</Snippet>
|
||||
<Link href="/dashboard"><Button>Dashboard</Button></Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ThemeProviderProps } from "next-themes";
|
||||
|
||||
import * as React from "react";
|
||||
import { HeroUIProvider } from "@heroui/system";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
@@ -21,11 +19,7 @@ declare module "@react-types/shared" {
|
||||
}
|
||||
|
||||
export function Providers({ children, themeProps }: ProvidersProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<HeroUIProvider navigate={router.push}>
|
||||
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
|
||||
</HeroUIProvider>
|
||||
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
|
||||
export const Counter = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<Button radius="full" onPress={() => setCount(count + 1)}>
|
||||
Count is {count}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,27 @@
|
||||
"use client"
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardBody, Divider } from "@heroui/react";
|
||||
import { Investigation } from '@/src/types/investigation';
|
||||
import Link from 'next/link';
|
||||
import { Box, Flex, Text, Card } from '@radix-ui/themes';
|
||||
|
||||
const investigation = ({ investigation }: { investigation: Investigation }) => {
|
||||
return (
|
||||
<Card as={Link} href={`/investigations/${investigation.id}`} className="w-full">
|
||||
<CardHeader className="flex gap-3">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-md">{investigation.title}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody>
|
||||
<p className='opacity-60 text-sm'>{investigation.description}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Link href={`/investigations/${investigation.id}`} >
|
||||
<Box className="w-full">
|
||||
<Card>
|
||||
<Flex gap="3" align="center">
|
||||
<Box>
|
||||
<Text as="div" size="2" weight="bold">
|
||||
{investigation.title}
|
||||
</Text>
|
||||
<Text as="div" size="2" color="gray">
|
||||
{investigation.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +1,82 @@
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
Button,
|
||||
Chip,
|
||||
} from "@heroui/react";
|
||||
"use client"
|
||||
import { DropdownMenu, Button, IconButton, Dialog, Flex, TextField, Text, Badge, Box } from "@radix-ui/themes";
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { JSX, SVGProps } from "react";
|
||||
import { useState } from "react";
|
||||
import { supabase } from '@/src/lib/supabase/client';
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export const AddIcon = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7.37 22h9.25a4.87 4.87 0 0 0 4.87-4.87V8.37a4.87 4.87 0 0 0-4.87-4.87H7.37A4.87 4.87 0 0 0 2.5 8.37v8.75c0 2.7 2.18 4.88 4.87 4.88Z"
|
||||
fill="currentColor"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<path
|
||||
d="M8.29 6.29c-.42 0-.75-.34-.75-.75V2.75a.749.749 0 1 1 1.5 0v2.78c0 .42-.33.76-.75.76ZM15.71 6.29c-.42 0-.75-.34-.75-.75V2.75a.749.749 0 1 1 1.5 0v2.78c0 .42-.33.76-.75.76ZM12 14.75h-1.69V13c0-.41-.34-.75-.75-.75s-.75.34-.75.75v1.75H7c-.41 0-.75.34-.75.75s.34.75.75.75h1.81V18c0 .41.34.75.75.75s.75-.34.75-.75v-1.75H12c.41 0 .75-.34.75-.75s-.34-.75-.75-.75Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export default function NewCase() {
|
||||
const iconClasses = "text-xl text-default-500 pointer-events-none flex-shrink-0";
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleNewCase(e: { preventDefault: () => void; currentTarget: HTMLFormElement | undefined; }) {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget))
|
||||
const investigation = await supabase.from("investigations").insert(data).select("id")
|
||||
.single()
|
||||
.then(({ data, error }) => {
|
||||
if (error)
|
||||
throw error
|
||||
return data
|
||||
})
|
||||
if (investigation)
|
||||
router.push(`/investigations/${investigation.id}`)
|
||||
setOpen(false)
|
||||
}
|
||||
return (
|
||||
<Dropdown
|
||||
backdrop="blur"
|
||||
classNames={{
|
||||
base: "before:bg-default-200", // change arrow background
|
||||
content:
|
||||
"py-1 px-1 border border-default-200 bg-gradient-to-br from-white to-default-200 dark:from-default-50 dark:to-black",
|
||||
}}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<PlusIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Dropdown menu with description" variant="faded" disabledKeys={["new_org"]}>
|
||||
<DropdownSection title="New">
|
||||
<DropdownItem
|
||||
key="new"
|
||||
description="Create a new investigation case."
|
||||
startContent={<AddIcon className={iconClasses} />}
|
||||
>
|
||||
New case
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="new_org"
|
||||
description="Create a new organization."
|
||||
startContent={<AddIcon className={iconClasses} />}
|
||||
>
|
||||
New organization <Chip size="sm" color="primary" variant="flat">Soon</Chip>
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown >
|
||||
<>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<IconButton color="gray" size="2" variant="soft">
|
||||
<PlusIcon className="h-5" />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content size="2">
|
||||
<DropdownMenu.Item onClick={() => setOpen(true)} shortcut="⌘ E">New case</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled shortcut="⌘ D">New organization <Badge radius="full" color="orange" size={"1"}>Soon</Badge></DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Content maxWidth="450px">
|
||||
<form onSubmit={handleNewCase}>
|
||||
<Box>
|
||||
<Dialog.Title>New case</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
Create a new blank case.
|
||||
</Dialog.Description>
|
||||
<Flex direction="column" gap="3">
|
||||
<label>
|
||||
<Text as="div" size="2" mb="1" weight="bold">
|
||||
Investigation name
|
||||
</Text>
|
||||
<TextField.Root
|
||||
required
|
||||
name="title"
|
||||
placeholder="Suspicion de fraude"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<Text as="div" size="2" mb="1" weight="bold">
|
||||
Description
|
||||
</Text>
|
||||
<TextField.Root
|
||||
name="description"
|
||||
placeholder="Investigation sur une campagne de phishing via Twitter et LinkedIn."
|
||||
/>
|
||||
</label>
|
||||
</Flex>
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button type="submit">Save</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root >
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useInvestigations } from "@/src/lib/hooks/investigation";
|
||||
import { Select, SelectItem } from "@heroui/react";
|
||||
import { useInvestigationContext } from "./investigation-provider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Select, Spinner } from "@radix-ui/themes";
|
||||
|
||||
export default function CaseSelector() {
|
||||
const router = useRouter()
|
||||
const { investigations, isLoading } = useInvestigations()
|
||||
const { investigation, isLoadingInvestigation } = useInvestigationContext()
|
||||
const handleSelectionChange = (e: { target: { value: any; }; }) => {
|
||||
router.push(`/investigations/${e.target.value}`);
|
||||
const handleSelectionChange = (value: string) => {
|
||||
router.push(`/investigations/${value}`);
|
||||
};
|
||||
return (
|
||||
<Select
|
||||
isLoading={isLoading || isLoadingInvestigation}
|
||||
radius="sm"
|
||||
variant="underlined"
|
||||
value={investigation?.id}
|
||||
items={investigations || []}
|
||||
placeholder="Select a case"
|
||||
onChange={handleSelectionChange}
|
||||
|
||||
>
|
||||
{(item: any) => <SelectItem key={item.id}>{item.title}</SelectItem>}
|
||||
</Select >
|
||||
<div className="ml-2 flex items-center">
|
||||
<Spinner loading={isLoading || isLoadingInvestigation}>
|
||||
<Select.Root onValueChange={handleSelectionChange} defaultValue={investigation?.id}>
|
||||
<Select.Trigger className="min-w-none w-full text-ellipsis truncate" variant="ghost" />
|
||||
<Select.Content>
|
||||
{investigations?.map((investigation) => (
|
||||
<Select.Item className="text-ellipsis truncate" key={investigation.id} value={investigation.id}>{investigation.title}</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root >
|
||||
</Spinner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
|
||||
export default function ContextMenu({
|
||||
id,
|
||||
top,
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
...props
|
||||
}: any) {
|
||||
const { getNode, setNodes, addNodes, setEdges } = useReactFlow();
|
||||
const duplicateNode = useCallback(() => {
|
||||
const node = getNode(id);
|
||||
if (!node) return
|
||||
const position = {
|
||||
x: node.position.x + 50,
|
||||
y: node.position.y + 50,
|
||||
};
|
||||
|
||||
addNodes({
|
||||
...node,
|
||||
selected: false,
|
||||
dragging: false,
|
||||
id: `${node.id}-copy`,
|
||||
position,
|
||||
});
|
||||
}, [id, getNode, addNodes]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
setNodes((nodes) => nodes.filter((node) => node.id !== id));
|
||||
setEdges((edges) => edges.filter((edge) => edge.source !== id));
|
||||
}, [id, setNodes, setEdges]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ top, left, right, bottom }}
|
||||
className="absolute"
|
||||
{...props}
|
||||
>
|
||||
<button onClick={duplicateNode}>duplicate</button>
|
||||
<button onClick={deleteNode}>delete</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { Chip, Card } from '@heroui/react';
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getStraightPath,
|
||||
getBezierPath,
|
||||
} from '@xyflow/react';
|
||||
import { useInvestigationContext } from './investigation-provider';
|
||||
import { Badge } from '@radix-ui/themes';
|
||||
|
||||
export default function CustomEdge({ id, label, confidence_level, sourceX, sourceY, targetX, targetY }: { id: string, label: string, confidence_level: string, sourceX: number, sourceY: number, targetX: number, targetY: number }) {
|
||||
const [edgePath, labelX, labelY] = getStraightPath({
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
@@ -20,15 +21,15 @@ export default function CustomEdge({ id, label, confidence_level, sourceX, sourc
|
||||
<BaseEdge id={id} path={edgePath} />
|
||||
<EdgeLabelRenderer>
|
||||
{settings.showEdgeLabel &&
|
||||
<Chip style={{
|
||||
<Badge color={label === "relation" ? 'orange' : "blue"} style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="nodrag nopan text-xs px-1" size='sm' color="warning" variant="flat">{label} {confidence_level &&
|
||||
className="nodrag nopan text-xs px-1">{label} {confidence_level &&
|
||||
<>{confidence_level}%</>}
|
||||
</Chip>}
|
||||
</EdgeLabelRenderer >
|
||||
</Badge>}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client"
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
Button,
|
||||
useDisclosure,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
DateInput,
|
||||
Avatar,
|
||||
} from "@heroui/react";
|
||||
import { useInvestigationContext } from './investigation-provider';
|
||||
|
||||
|
||||
function CustomNode({ data }: any) {
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const { settings } = useInvestigationContext()
|
||||
return (
|
||||
<>
|
||||
<div onClick={onOpen} className="px-4 py-2 rounded-md bg-background border-2 border-foreground/10">
|
||||
<div className="flex">
|
||||
<Avatar showFallback src="https://images.unsplash.com/broken" />
|
||||
{settings.showNodeLabel && <div className="ml-2">
|
||||
<div className="text-lg font-bold">{data.full_name}</div>
|
||||
<div className="text-gray-500">{data.notes}</div>
|
||||
</div>}
|
||||
</div>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<Drawer backdrop="blur" isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<DrawerContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<DrawerHeader className="flex flex-col gap-1">{data.full_name}</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<p className='opacity-60'>
|
||||
{data.notes}
|
||||
</p>
|
||||
<DateInput
|
||||
className="max-w-sm"
|
||||
label={"Birth date"}
|
||||
/>
|
||||
<Accordion>
|
||||
<AccordionItem key="1" aria-label="Social accounts" title={"Social accounts"}>
|
||||
<ul>
|
||||
{data?.social_accounts?.map((account: any) => (
|
||||
<li className='flex items-center justify-between' key={account.id}>{account.platform}<a className='underline text-primary' href={account.profile_url}>{account.username}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="2" aria-label="Accordion 2" title={"IP Addresses"}>
|
||||
<ul>
|
||||
{data?.ip_addresses?.map((ip: any) => (
|
||||
<li className='flex items-center justify-between' key={ip.id}>{ip.ip_address}<span>{ip.geolocation?.city}, {ip.geolocation?.country}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="3" aria-label="Accordion 3" title={"Phone numbers"}>
|
||||
<ul> {data?.phone_numbers?.map((number: any) => (
|
||||
<li className='flex items-center justify-between' key={number.id}>{number.phone_number}<span>{number.country}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
Action
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CustomNode);
|
||||
@@ -15,17 +15,17 @@ import {
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { supabase } from '@/src/lib/supabase/client';
|
||||
import CustomNode from './custom-node';
|
||||
import IndividualNode from './nodes/individual';
|
||||
import PhoneNode from './nodes/phone';
|
||||
import CustomEdge from './custom-edge';
|
||||
import { Button } from '@heroui/button';
|
||||
import { AlignCenterHorizontal, AlignCenterVertical, MaximizeIcon, PlusIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||
import ContextMenu from './context-menu';
|
||||
import IpNode from './nodes/ip_address';
|
||||
import EmailNode from './nodes/email';
|
||||
import { AlignCenterHorizontal, AlignCenterVertical, MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Spinner, Tooltip } from '@heroui/react';
|
||||
import NewActions from './new-actions';
|
||||
import { useInvestigationContext } from './investigation-provider';
|
||||
import { IconButton, Tooltip, Spinner } from '@radix-ui/themes';
|
||||
|
||||
const nodeTypes = { custom: CustomNode };
|
||||
const nodeTypes = { individual: IndividualNode, phone: PhoneNode, ip: IpNode, email: EmailNode };
|
||||
const edgeTypes = {
|
||||
'custom': CustomEdge,
|
||||
};
|
||||
@@ -59,20 +59,16 @@ const getLayoutedElements = (nodes: any[], edges: any[], options: { direction: a
|
||||
};
|
||||
|
||||
const LayoutFlow = ({ initialNodes, initialEdges, theme }: { initialNodes: any, initialEdges: any, theme: ColorMode }) => {
|
||||
const { fitView, zoomIn, zoomOut, addEdges, addNodes } = useReactFlow();
|
||||
const { investigation } = useInvestigationContext()
|
||||
const { fitView, zoomIn, zoomOut, addNodes } = useReactFlow();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [menu, setMenu] = useState<null | any>(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
const onLayout = useCallback(
|
||||
(direction: any) => {
|
||||
const layouted = getLayoutedElements(nodes, edges, { direction });
|
||||
|
||||
setNodes([...layouted.nodes]);
|
||||
setEdges([...layouted.edges]);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
fitView();
|
||||
});
|
||||
@@ -80,56 +76,14 @@ const LayoutFlow = ({ initialNodes, initialEdges, theme }: { initialNodes: any,
|
||||
[nodes, edges],
|
||||
);
|
||||
const onConnect = useCallback(
|
||||
(params: any) => setEdges((els) => addEdge(params, els)),
|
||||
async (params: any) => {
|
||||
console.log(params)
|
||||
await supabase.from("relationships")
|
||||
.insert({ individual_a: params.source, individual_b: params.target, relation_type: "relation" })
|
||||
setEdges((els) => addEdge({ ...params, label: "relation", type: "custom" }, els))
|
||||
},
|
||||
[setEdges],
|
||||
);
|
||||
const onNodeContextMenu = useCallback(
|
||||
(event: { preventDefault: () => void; clientY: number; clientX: number; }, node: { id: any; }) => {
|
||||
event.preventDefault();
|
||||
// @ts-ignore
|
||||
const pane = ref.current.getBoundingClientRect();
|
||||
setMenu({
|
||||
id: node.id,
|
||||
top: event.clientY < pane.height - 100 && event.clientY,
|
||||
left: event.clientX < pane.width - 100 && event.clientX,
|
||||
right: event.clientX >= pane.width - 100 && pane.width - event.clientX,
|
||||
bottom:
|
||||
event.clientY >= pane.height - 100 && pane.height - event.clientY,
|
||||
});
|
||||
},
|
||||
[setMenu],
|
||||
);
|
||||
const onPaneClick = useCallback(() => setMenu(null), [setMenu]);
|
||||
|
||||
const handleAddNode = async () => {
|
||||
if (!investigation) return
|
||||
// create individual
|
||||
const individual = await supabase.from("individuals").insert({
|
||||
full_name: "Franck Marshall",
|
||||
}).select("*")
|
||||
.single()
|
||||
.then(({ data, error }) => {
|
||||
if (error)
|
||||
console.log(error)
|
||||
return data
|
||||
})
|
||||
if (!individual) return
|
||||
// create relation to investigation
|
||||
await supabase.from("investigation_individuals").insert({
|
||||
individual_id: individual.id,
|
||||
investigation_id: investigation.id
|
||||
}).then(({ error }) => console.log(error))
|
||||
addNodes({
|
||||
id: individual.id.toString(),
|
||||
type: 'custom',
|
||||
data: { ...individual, label: individual.full_name },
|
||||
position: { x: 0, y: 100 }
|
||||
});
|
||||
}
|
||||
|
||||
const handleAddEdge = async () => {
|
||||
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onLayout('LR')
|
||||
@@ -145,50 +99,47 @@ const LayoutFlow = ({ initialNodes, initialEdges, theme }: { initialNodes: any,
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onPaneClick={onPaneClick}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
fitView
|
||||
nodeTypes={nodeTypes}
|
||||
// @ts-ignore
|
||||
edgeTypes={edgeTypes}
|
||||
>
|
||||
<Panel position="top-left" className='flex items-center gap-1'>
|
||||
<Tooltip showArrow content="Auto layout (vertical)" placement={"bottom-start"}>
|
||||
<Button onPress={() => onLayout('TB')} size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<Tooltip content="Auto layout (vertical)">
|
||||
<IconButton color="gray" variant="soft" onClick={() => onLayout('TB')}>
|
||||
<AlignCenterVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip showArrow content="Auto layout (horizontal)" placement={"bottom-start"}>
|
||||
<Button onPress={() => onLayout('LR')} size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<Tooltip content="Auto layout (horizontal)">
|
||||
<IconButton color="gray" variant="soft" onClick={() => onLayout('LR')}>
|
||||
<AlignCenterHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
<Panel position="top-right" className='flex items-center gap-1'>
|
||||
<NewActions handleAddEdge={handleAddEdge} handleAddNode={handleAddNode} />
|
||||
<NewActions addNodes={addNodes} />
|
||||
</Panel>
|
||||
<Panel position="bottom-left" className='flex flex-col items-center gap-1'>
|
||||
<Tooltip showArrow delay={1000} content="Center view" placement={"right"}>
|
||||
<Tooltip content="Center view">
|
||||
{/* @ts-ignore */}
|
||||
<Button onPress={fitView} placement={"right"} size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<IconButton color="gray" variant="soft" onClick={fitView}>
|
||||
<MaximizeIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip showArrow delay={1000} placement={"right"} content="Zoom in">
|
||||
<Tooltip content="Zoom in">
|
||||
{/* @ts-ignore */}
|
||||
<Button onPress={zoomIn} size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<IconButton color="gray" variant="soft" onClick={zoomIn}>
|
||||
<ZoomInIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip showArrow delay={1000} placement={"right"} content="Zoom out">
|
||||
<Tooltip content="Zoom out">
|
||||
{/* @ts-ignore */}
|
||||
<Button onPress={zoomOut} size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<IconButton color="gray" variant="soft" onClick={zoomOut}>
|
||||
<ZoomOutIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
<Background />
|
||||
{menu && <ContextMenu onClick={onPaneClick} {...menu} />}
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
@@ -203,7 +154,8 @@ export default function (props: any) {
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return <div className='h-[calc(100vh_-_48px)] w-full flex items-center justify-center'><Spinner color="primary" label="Loading schema..." /></div>
|
||||
return <div className='h-[calc(100vh_-_48px)] w-full flex items-center justify-center'><Spinner size="3" />
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
"use client"
|
||||
import useLocalStorage from "@/src/lib/use-local-storage";
|
||||
import React, { createContext, useContext, ReactNode, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
useDisclosure,
|
||||
cn,
|
||||
Switch,
|
||||
} from "@heroui/react";
|
||||
import { notFound, useParams } from "next/navigation";
|
||||
import { Button, Dialog, Flex, Switch } from "@radix-ui/themes";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Investigation } from "@/src/types/investigation";
|
||||
import { useInvestigation } from "@/src/lib/hooks/investigation";
|
||||
import { supabase } from "@/src/lib/supabase/client";
|
||||
import { ThemeSwitch } from "../theme-switch";
|
||||
|
||||
interface InvestigationContextType {
|
||||
filters: any,
|
||||
setFilters: any,
|
||||
settings: any,
|
||||
setSettings: any,
|
||||
handleOpenSettings: any,
|
||||
setOpenSettingsModal: any,
|
||||
investigation: Investigation | null,
|
||||
isLoadingInvestigation: boolean | undefined
|
||||
isLoadingInvestigation: boolean | undefined,
|
||||
}
|
||||
|
||||
const InvestigationContext = createContext<InvestigationContextType | undefined>(undefined);
|
||||
|
||||
interface InvestigationProviderProps {
|
||||
@@ -35,67 +27,65 @@ export const InvestigationProvider: React.FC<InvestigationProviderProps> = ({ ch
|
||||
const [filters, setFilters] = useLocalStorage('filters', {});
|
||||
const { investigation_id } = useParams()
|
||||
const { investigation, isLoading: isLoadingInvestigation } = useInvestigation(investigation_id)
|
||||
const { isOpen: openSettingsModal, onOpen: handleOpenSettings, onOpenChange: openChangeSettingsModal } = useDisclosure();
|
||||
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false)
|
||||
const [settings, setSettings] = useLocalStorage('settings', {
|
||||
showNodeLabel: true,
|
||||
showEdgeLabel: true
|
||||
});
|
||||
|
||||
const handleDeleteNode = async (id: string) => {
|
||||
await supabase.from("individuals").delete().eq("id", id)
|
||||
.then(({ data, error }) => {
|
||||
if (error)
|
||||
alert('an error occured.')
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
const SettingSwitch = ({ setting, value, title, description }: { setting: string, value: boolean, title: string, description: string }) => (
|
||||
<Switch
|
||||
isSelected={value} onValueChange={(val) => setSettings({ ...settings, [setting]: val })}
|
||||
classNames={{
|
||||
base: cn(
|
||||
"inline-flex flex-row-reverse w-full max-w-none bg-content1 hover:bg-content2 items-center",
|
||||
"justify-between cursor-pointer rounded-lg gap-2 p-4",
|
||||
),
|
||||
wrapper: "p-0 h-4 overflow-visible",
|
||||
thumb: cn(
|
||||
"w-6 h-6 border-2 shadow-lg",
|
||||
"group-data-[hover=true]:border-primary",
|
||||
//selected
|
||||
"group-data-[selected=true]:ms-6",
|
||||
// pressed
|
||||
"group-data-[pressed=true]:w-7",
|
||||
"group-data-[selected]:group-data-[pressed]:ms-4",
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-medium">{title}</p>
|
||||
<p className="text-tiny text-default-400">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="opacity-60 text-sm">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Switch>
|
||||
<Switch checked={value} onCheckedChange={(val: boolean) => setSettings({ ...settings, [setting]: val })} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<InvestigationContext.Provider value={{ filters, setFilters, settings, setSettings, handleOpenSettings, investigation, isLoadingInvestigation }}>
|
||||
<InvestigationContext.Provider value={{ filters, setFilters, settings, setSettings, setOpenSettingsModal, investigation, isLoadingInvestigation }}>
|
||||
{children}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
size="2xl" isOpen={openSettingsModal} onOpenChange={openChangeSettingsModal}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Settings</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<SettingSwitch setting={"showNodeLabel"} value={settings.showNodeLabel} title={"Show labels on nodes"} description={"Displays the labels on the nodes, like username or avatar."} />
|
||||
<SettingSwitch setting={"showEdgeLabel"} value={settings.showEdgeLabel} title={"Show labels on edeges"} description={"Displays the labels on the edges, like relation type."} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</InvestigationContext.Provider>
|
||||
<Dialog.Root open={openSettingsModal}>
|
||||
<Dialog.Content maxWidth="450px">
|
||||
<Dialog.Title>Settings</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
Make changes to your settings.
|
||||
</Dialog.Description>
|
||||
<Flex direction="column" gap="3">
|
||||
<SettingSwitch setting={"showNodeLabel"} value={settings.showNodeLabel} title={"Show labels on nodes"} description={"Displays the labels on the nodes, like username or avatar."} />
|
||||
<SettingSwitch setting={"showEdgeLabel"} value={settings.showEdgeLabel} title={"Show labels on edeges"} description={"Displays the labels on the edges, like relation type."} />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">Theme</p>
|
||||
<p className="opacity-60 text-sm">
|
||||
Switch to dark or light mode.
|
||||
</p>
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
|
||||
</Flex>
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close onClick={() => setOpenSettingsModal(false)}>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</InvestigationContext.Provider >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
import { Investigation } from '@/src/types/investigation';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardFooter, CardHeader, Divider } from '@heroui/react';
|
||||
import React from 'react'
|
||||
import { ThemeSwitch } from '../theme-switch';
|
||||
import MoreMenu from './more-menu';
|
||||
@@ -18,31 +16,23 @@ const InvestigationLayout = ({
|
||||
return (
|
||||
<div className='h-screen w-screen flex'>
|
||||
<div className='flex'>
|
||||
<Card className='w-[240px] rounded-none shadow-none border-r border-foreground/10'>
|
||||
<Card className='w-full rounded-none shadow-none h-12 border-b border-foreground/10 flex items-center flex-row justify-end p-2'>
|
||||
<div className='w-[240px] flex flex-col rounded-none shadow-none border-r border-gray-400/20'>
|
||||
<div className='w-full rounded-none shadow-none h-12 border-b border-gray-400/20 flex items-center flex-row justify-end p-2'>
|
||||
<NewCase />
|
||||
</Card>
|
||||
<CardHeader>
|
||||
</div>
|
||||
<div className='p-3'>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-md">{investigation.title}</p>
|
||||
<p className="text-sm opacity-60">{investigation.description}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className='grow'>
|
||||
<CaseSelector />
|
||||
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<div className='flex w-full items-center justify-end'>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow flex flex-col'>
|
||||
<Card className='w-full rounded-none shadow-none h-12 justify-end border-b border-foreground/10 flex flex-row items-center p-2'>
|
||||
<div className='w-full rounded-none shadow-none h-12 justify-between border-b border-gray-400/20 flex flex-row items-center p-2'>
|
||||
<CaseSelector />
|
||||
<MoreMenu />
|
||||
</Card>
|
||||
</div>
|
||||
{children}
|
||||
</div></div>
|
||||
)
|
||||
|
||||
@@ -1,164 +1,21 @@
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
Button,
|
||||
cn,
|
||||
Divider,
|
||||
} from "@heroui/react";
|
||||
import { Ellipsis, SettingsIcon } from 'lucide-react';
|
||||
import { JSX, SVGProps } from "react";
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
import { useInvestigationContext } from "./investigation-provider";
|
||||
import { DropdownMenu, IconButton } from "@radix-ui/themes";
|
||||
|
||||
|
||||
export const CopyDocumentIcon = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15.5 13.15h-2.17c-1.78 0-3.23-1.44-3.23-3.23V7.75c0-.41-.33-.75-.75-.75H6.18C3.87 7 2 8.5 2 11.18v6.64C2 20.5 3.87 22 6.18 22h5.89c2.31 0 4.18-1.5 4.18-4.18V13.9c0-.42-.34-.75-.75-.75Z"
|
||||
fill="currentColor"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<path
|
||||
d="M17.82 2H11.93C9.67 2 7.84 3.44 7.76 6.01c.06 0 .11-.01.17-.01h5.89C16.13 6 18 7.5 18 10.18V16.83c0 .06-.01.11-.01.16 2.23-.07 4.01-1.55 4.01-4.16V6.18C22 3.5 20.13 2 17.82 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.98 7.15c-.31-.31-.84-.1-.84.33v2.62c0 1.1.93 2 2.07 2 .71.01 1.7.01 2.55.01.43 0 .65-.5.35-.8-1.09-1.09-3.03-3.04-4.13-4.16Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditDocumentIcon = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15.48 3H7.52C4.07 3 2 5.06 2 8.52v7.95C2 19.94 4.07 22 7.52 22h7.95c3.46 0 5.52-2.06 5.52-5.52V8.52C21 5.06 18.93 3 15.48 3Z"
|
||||
fill="currentColor"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<path
|
||||
d="M21.02 2.98c-1.79-1.8-3.54-1.84-5.38 0L14.51 4.1c-.1.1-.13.24-.09.37.7 2.45 2.66 4.41 5.11 5.11.03.01.08.01.11.01.1 0 .2-.04.27-.11l1.11-1.12c.91-.91 1.36-1.78 1.36-2.67 0-.9-.45-1.79-1.36-2.71ZM17.86 10.42c-.27-.13-.53-.26-.77-.41-.2-.12-.4-.25-.59-.39-.16-.1-.34-.25-.52-.4-.02-.01-.08-.06-.16-.14-.31-.25-.64-.59-.95-.96-.02-.02-.08-.08-.13-.17-.1-.11-.25-.3-.38-.51-.11-.14-.24-.34-.36-.55-.15-.25-.28-.5-.4-.76-.13-.28-.23-.54-.32-.79L7.9 10.72c-.35.35-.69 1.01-.76 1.5l-.43 2.98c-.09.63.08 1.22.47 1.61.33.33.78.5 1.28.5.11 0 .22-.01.33-.02l2.97-.42c.49-.07 1.15-.4 1.5-.76l5.38-5.38c-.25-.08-.5-.19-.78-.31Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteDocumentIcon = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M21.07 5.23c-1.61-.16-3.22-.28-4.84-.37v-.01l-.22-1.3c-.15-.92-.37-2.3-2.71-2.3h-2.62c-2.33 0-2.55 1.32-2.71 2.29l-.21 1.28c-.93.06-1.86.12-2.79.21l-2.04.2c-.42.04-.72.41-.68.82.04.41.4.71.82.67l2.04-.2c5.24-.52 10.52-.32 15.82.21h.08c.38 0 .71-.29.75-.68a.766.766 0 0 0-.69-.82Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19.23 8.14c-.24-.25-.57-.39-.91-.39H5.68c-.34 0-.68.14-.91.39-.23.25-.36.59-.34.94l.62 10.26c.11 1.52.25 3.42 3.74 3.42h6.42c3.49 0 3.63-1.89 3.74-3.42l.62-10.25c.02-.36-.11-.7-.34-.95Z"
|
||||
fill="currentColor"
|
||||
opacity={0.399}
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9.58 17a.75.75 0 0 1 .75-.75h3.33a.75.75 0 0 1 0 1.5h-3.33a.75.75 0 0 1-.75-.75ZM8.75 13a.75.75 0 0 1 .75-.75h5a.75.75 0 0 1 0 1.5h-5a.75.75 0 0 1-.75-.75Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MoreMenu() {
|
||||
const iconClasses = "text-xl text-default-500 pointer-events-none flex-shrink-0";
|
||||
const { handleOpenSettings } = useInvestigationContext()
|
||||
const { setOpenSettingsModal } = useInvestigationContext()
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
backdrop="blur"
|
||||
classNames={{
|
||||
base: "before:bg-default-200", // change arrow background
|
||||
content:
|
||||
"py-1 px-1 border border-default-200 bg-gradient-to-br from-white to-default-200 dark:from-default-50 dark:to-black",
|
||||
}}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button size="sm" isIconOnly aria-label="options" variant='bordered'>
|
||||
<Ellipsis className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Dropdown menu with description" variant="faded">
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="copy"
|
||||
description="Copy the case link"
|
||||
shortcut="⌘C"
|
||||
startContent={<CopyDocumentIcon className={iconClasses} />}
|
||||
>
|
||||
Copy link
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Allows you to edit the case infos"
|
||||
shortcut="⌘⇧E"
|
||||
startContent={<EditDocumentIcon className={iconClasses} />}
|
||||
>
|
||||
Edit case
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
{/* <Divider /> */}
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
onPress={handleOpenSettings}
|
||||
key="settings"
|
||||
description="Manage settings"
|
||||
startContent={<SettingsIcon className={iconClasses} />}
|
||||
>
|
||||
Settings
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-danger"
|
||||
color="danger"
|
||||
description="Permanently delete the case"
|
||||
shortcut="⌘⇧D"
|
||||
startContent={<DeleteDocumentIcon className={cn(iconClasses, "text-danger")} />}
|
||||
>
|
||||
Delete case
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<IconButton color="gray" variant="soft" size="2">
|
||||
<Ellipsis className="h-4" />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content size="2">
|
||||
<DropdownMenu.Item onClick={setOpenSettingsModal} shortcut="⌘ E">Settings</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,58 @@
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
Button,
|
||||
} from "@heroui/react";
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { JSX, SVGProps } from "react";
|
||||
import { supabase } from "@/src/lib/supabase/client";
|
||||
import { DropdownMenu, Button, Popover, Flex, Box, TextField, Avatar } from "@radix-ui/themes";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export const AddIcon = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => {
|
||||
|
||||
export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
const { investigation_id } = useParams()
|
||||
const onSubmit = async (e: { preventDefault: () => void; currentTarget: HTMLFormElement | undefined; }) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget));
|
||||
const node = await supabase.from("individuals").insert({ ...data, investigation_id: investigation_id?.toString() }).select("*")
|
||||
.single()
|
||||
.then(({ data, error }) => {
|
||||
if (error)
|
||||
console.error(error)
|
||||
return data
|
||||
})
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: "individual",
|
||||
data: node,
|
||||
position: { x: -100, y: -100 }
|
||||
});
|
||||
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7.37 22h9.25a4.87 4.87 0 0 0 4.87-4.87V8.37a4.87 4.87 0 0 0-4.87-4.87H7.37A4.87 4.87 0 0 0 2.5 8.37v8.75c0 2.7 2.18 4.88 4.87 4.88Z"
|
||||
fill="currentColor"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<path
|
||||
d="M8.29 6.29c-.42 0-.75-.34-.75-.75V2.75a.749.749 0 1 1 1.5 0v2.78c0 .42-.33.76-.75.76ZM15.71 6.29c-.42 0-.75-.34-.75-.75V2.75a.749.749 0 1 1 1.5 0v2.78c0 .42-.33.76-.75.76ZM12 14.75h-1.69V13c0-.41-.34-.75-.75-.75s-.75.34-.75.75v1.75H7c-.41 0-.75.34-.75.75s.34.75.75.75h1.81V18c0 .41.34.75.75.75s.75-.34.75-.75v-1.75H12c.41 0 .75-.34.75-.75s-.34-.75-.75-.75Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export default function NewActions({ handleAddNode, handleAddEdge }: any) {
|
||||
const iconClasses = "text-xl text-default-500 pointer-events-none flex-shrink-0";
|
||||
return (
|
||||
<Dropdown
|
||||
classNames={{
|
||||
base: "before:bg-default-200", // change arrow background
|
||||
content:
|
||||
"py-1 px-1 border border-default-200 bg-gradient-to-br from-white to-default-200 dark:from-default-50 dark:to-black",
|
||||
}}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button radius='sm' startContent={<PlusIcon className='h-4 w-4' />} size="sm" aria-label="new_individual" color='primary'>
|
||||
New
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="soft">
|
||||
<PlusIcon width="16" height="16" />
|
||||
Individual
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Dropdown menu with description" variant="faded">
|
||||
<DropdownSection title="New">
|
||||
<DropdownItem
|
||||
onPress={handleAddNode}
|
||||
key="new_individual"
|
||||
description="Create a new individual profile."
|
||||
shortcut="⌘N"
|
||||
startContent={<AddIcon className={iconClasses} />}
|
||||
>
|
||||
New individual
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
onPress={handleAddEdge}
|
||||
key="new_relation"
|
||||
shortcut="⌘R"
|
||||
description="Create a new relation between two individuals."
|
||||
startContent={<AddIcon className={iconClasses} />}
|
||||
>
|
||||
New relation
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown >
|
||||
</Popover.Trigger>
|
||||
<Popover.Content width="260px">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Flex gap="3">
|
||||
<Avatar
|
||||
size="3"
|
||||
fallback="A"
|
||||
radius="full"
|
||||
/>
|
||||
<Box flexGrow="1">
|
||||
<TextField.Root
|
||||
defaultValue={""}
|
||||
name={"full_name"}
|
||||
placeholder={`Name of the individual`}
|
||||
/>
|
||||
<Flex justify={"end"} className="mt-2">
|
||||
<Button type="submit" size="2">Add</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</form>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/components/investigations/nodes/email.tsx
Normal file
56
src/components/investigations/nodes/email.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Card, Box, Text, ContextMenu, Badge, Flex, Inset } from '@radix-ui/themes';
|
||||
import { NodeProvider, useNodeContext } from './node-context';
|
||||
import { AtSignIcon } from 'lucide-react';
|
||||
|
||||
function EmailNode({ data }: any) {
|
||||
const { handleDeleteNode } = useNodeContext()
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<Box>
|
||||
<Card>
|
||||
<Inset>
|
||||
<Flex className='items-center p-0'>
|
||||
<Badge className='!h-[24px] !rounded-r-none'>
|
||||
<AtSignIcon className='h-3 w-3' />
|
||||
</Badge>
|
||||
<Box className='p-1'>
|
||||
<Text as="div" size="1" weight="regular">
|
||||
{data.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Inset>
|
||||
</Card>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item shortcut="⌘ C">Copy content</ContextMenu.Item>
|
||||
<ContextMenu.Item shortcut="⌘ D">Duplicate</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onClick={handleDeleteNode} shortcut="⌘ ⌫" color="red">
|
||||
Delete
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoizedNode = (props: any) => (
|
||||
<NodeProvider>
|
||||
<EmailNode {...props} />
|
||||
</NodeProvider>
|
||||
);
|
||||
|
||||
export default memo(MemoizedNode);
|
||||
134
src/components/investigations/nodes/individual.tsx
Normal file
134
src/components/investigations/nodes/individual.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
import React, { memo, useState } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { useInvestigationContext } from '../investigation-provider';
|
||||
import { Avatar, Card, Box, Flex, Text, ContextMenu, Dialog, TextField, Button } from '@radix-ui/themes';
|
||||
import { AtSignIcon, CameraIcon, FacebookIcon, InstagramIcon, LocateIcon, PhoneIcon, UserIcon } from 'lucide-react';
|
||||
import { NodeProvider, useNodeContext } from './node-context';
|
||||
|
||||
function Custom({ data }: any) {
|
||||
console.log(data)
|
||||
const { settings } = useInvestigationContext()
|
||||
const { setOpenAddNodeModal, handleDuplicateNode, handleDeleteNode } = useNodeContext()
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!data) return
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<Box>{settings.showNodeLabel ?
|
||||
<Card className='!py-1' onClick={() => setOpen(true)}>
|
||||
<Flex gap="2" align="center">
|
||||
<Avatar
|
||||
size="2"
|
||||
src={data?.image_url}
|
||||
radius="full"
|
||||
fallback={data.full_name?.[0]}
|
||||
/>
|
||||
{settings.showNodeLabel &&
|
||||
<Box>
|
||||
<Text as="div" size="2" weight="bold">
|
||||
{data.full_name}
|
||||
</Text>
|
||||
<Text as="div" size="2" color="gray">
|
||||
{data.notes}
|
||||
</Text>
|
||||
</Box>}
|
||||
</Flex>
|
||||
</Card> : <Avatar
|
||||
size="3"
|
||||
src={data?.image_url}
|
||||
radius="full"
|
||||
fallback={data.full_name?.[0]}
|
||||
/>}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
</Box>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Sub>
|
||||
<ContextMenu.SubTrigger >New</ContextMenu.SubTrigger>
|
||||
<ContextMenu.SubContent>
|
||||
<ContextMenu.Item onClick={() => setOpenAddNodeModal("individuals")}><UserIcon className='h-4 w-4' /> New relation</ContextMenu.Item>
|
||||
<ContextMenu.Item onClick={() => setOpenAddNodeModal("phone_numbers", data.id)}><PhoneIcon className='h-4 w-4' />Phone number</ContextMenu.Item>
|
||||
<ContextMenu.Item onClick={() => setOpenAddNodeModal("emails", data.id)}><AtSignIcon className='h-4 w-4' />Email address</ContextMenu.Item>
|
||||
<ContextMenu.Item onClick={() => setOpenAddNodeModal("ip_addresses", data.id)}><LocateIcon className='h-4 w-4' />IP address</ContextMenu.Item>
|
||||
<ContextMenu.Sub>
|
||||
<ContextMenu.SubTrigger >Social account</ContextMenu.SubTrigger>
|
||||
<ContextMenu.SubContent>
|
||||
<ContextMenu.Item><FacebookIcon className='h-4 w-4' />Facebook</ContextMenu.Item>
|
||||
<ContextMenu.Item><InstagramIcon className='h-4 w-4' />Instagram</ContextMenu.Item>
|
||||
<ContextMenu.Item><CameraIcon className='h-4 w-4' />Snapchat</ContextMenu.Item>
|
||||
<ContextMenu.Item>Coco</ContextMenu.Item>
|
||||
</ContextMenu.SubContent>
|
||||
</ContextMenu.Sub>
|
||||
</ContextMenu.SubContent>
|
||||
</ContextMenu.Sub>
|
||||
<ContextMenu.Item onClick={() => setOpen(true)}>View and edit</ContextMenu.Item>
|
||||
<ContextMenu.Item onClick={handleDuplicateNode}>Duplicate</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onClick={handleDeleteNode} shortcut="⌘ ⌫" color="red">
|
||||
Delete
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Content maxWidth="40vw">
|
||||
<Dialog.Title>{data.full_name}</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
{data.notes}
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex direction="column" gap="3">
|
||||
<label>
|
||||
<Text as="div" size="2" mb="1" weight="bold">
|
||||
Name
|
||||
</Text>
|
||||
<TextField.Root
|
||||
defaultValue={data?.full_name}
|
||||
placeholder="Enter your full name"
|
||||
name="full_name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<Text as="div" size="2" mb="1" weight="bold">
|
||||
Email
|
||||
</Text>
|
||||
<TextField.Root
|
||||
defaultValue="freja@example.com"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</label>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button>Save</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const IndividualNode = (props: any) => (
|
||||
<NodeProvider>
|
||||
<Custom {...props} />
|
||||
</NodeProvider>
|
||||
);
|
||||
|
||||
export default memo(IndividualNode);
|
||||
56
src/components/investigations/nodes/ip_address.tsx
Normal file
56
src/components/investigations/nodes/ip_address.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Card, Box, Text, ContextMenu, Flex, Inset, Badge } from '@radix-ui/themes';
|
||||
import { NodeProvider, useNodeContext } from './node-context';
|
||||
import { LocateIcon } from 'lucide-react';
|
||||
|
||||
function Custom({ data }: any) {
|
||||
const { handleDeleteNode } = useNodeContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<Box>
|
||||
<Card>
|
||||
<Inset>
|
||||
<Flex className='items-center p-0'>
|
||||
<Badge className='!h-[24px] !rounded-r-none'>
|
||||
<LocateIcon className='h-3 w-3' />
|
||||
</Badge>
|
||||
<Box className='p-1'>
|
||||
<Text as="div" size="1" weight="regular">
|
||||
{data.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Inset>
|
||||
</Card>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
</Box>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item shortcut="⌘ C">Copy content</ContextMenu.Item>
|
||||
<ContextMenu.Item shortcut="⌘ D">Duplicate</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onClick={handleDeleteNode} shortcut="⌘ ⌫" color="red">
|
||||
Delete
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const IpNode = (props: any) => (
|
||||
<NodeProvider>
|
||||
<Custom {...props} />
|
||||
</NodeProvider>
|
||||
);
|
||||
|
||||
export default memo(IpNode);
|
||||
166
src/components/investigations/nodes/node-context.tsx
Normal file
166
src/components/investigations/nodes/node-context.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
import React, { createContext, useContext, ReactNode, useState, useCallback } from "react";
|
||||
import { Button, Dialog, Flex, Text, TextField } from "@radix-ui/themes";
|
||||
import { useParams } from "next/navigation";
|
||||
import { supabase } from "@/src/lib/supabase/client";
|
||||
import { useNodeId, useReactFlow } from "@xyflow/react";
|
||||
|
||||
interface NodeContextType {
|
||||
setOpenAddNodeModal: any,
|
||||
handleDuplicateNode: any,
|
||||
handleDeleteNode: any
|
||||
}
|
||||
|
||||
const nodesTypes = {
|
||||
"emails": { table: "emails", type: "email", field: "email" },
|
||||
"individuals": { table: "individuals", type: "individual", field: "full_name" },
|
||||
"phone_numbers": { table: "phone_numbers", type: "phone", field: "phone_number" },
|
||||
"ip_addresses": { table: "ip_addresses", type: "ip", field: "ip_address" },
|
||||
"social_accounts": { table: "social_accounts", type: "social", field: "username" },
|
||||
}
|
||||
|
||||
const NodeContext = createContext<NodeContextType | undefined>(undefined);
|
||||
|
||||
interface NodeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
|
||||
const { addNodes, addEdges, setNodes, setEdges } = useReactFlow();
|
||||
const { investigation_id } = useParams()
|
||||
const [openAddNodeModal, setOpenNodeModal] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [nodeType, setnodeType] = useState<any | null>(null)
|
||||
const nodeId = useNodeId();
|
||||
|
||||
const setOpenAddNodeModal = (tableName: string) => {
|
||||
// @ts-ignore
|
||||
if (!nodesTypes[tableName]) return
|
||||
// @ts-ignore
|
||||
setnodeType(nodesTypes[tableName])
|
||||
setOpenNodeModal(true)
|
||||
}
|
||||
|
||||
const onSubmitNewNodeModal = (e: { preventDefault: () => void; currentTarget: HTMLFormElement | undefined; }) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget));
|
||||
handleAddNode(data);
|
||||
};
|
||||
|
||||
const handleAddNode = async (data: any) => {
|
||||
setLoading(true)
|
||||
if (!nodeId) return alert("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)
|
||||
console.log(error)
|
||||
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))
|
||||
|
||||
await supabase.from("relationships").insert({
|
||||
individual_a: nodeId,
|
||||
individual_b: node.id,
|
||||
relation_type: "relation"
|
||||
}).then(({ error }) => console.log(error))
|
||||
}
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: nodeType.type,
|
||||
data: { ...node, label: node[nodeType.field] },
|
||||
position: { x: -100, y: -100 }
|
||||
});
|
||||
addEdges({
|
||||
source: nodeId,
|
||||
target: node.id,
|
||||
type: 'custom',
|
||||
id: `${nodeId}-${node.id}`.toString(),
|
||||
label: nodeType.type,
|
||||
});
|
||||
setLoading(false)
|
||||
setOpenNodeModal(false)
|
||||
}
|
||||
|
||||
const handleDuplicateNode = async () => {
|
||||
await supabase.from("individuals")
|
||||
.select("*")
|
||||
.eq("id", nodeId)
|
||||
.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) throw error
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: "individual",
|
||||
data: node,
|
||||
position: { x: 0, y: -100 }
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteNode = useCallback(async () => {
|
||||
if (!nodeId) return
|
||||
setNodes((nodes: any[]) => nodes.filter((node: { id: any; }) => node.id !== nodeId.toString()));
|
||||
setEdges((edges: any[]) => edges.filter((edge: { source: any; }) => edge.source !== nodeId.toString()));
|
||||
}, [nodeId, setNodes, setEdges]);
|
||||
|
||||
return (
|
||||
<NodeContext.Provider {...props} value={{ setOpenAddNodeModal, handleDuplicateNode, handleDeleteNode }}>
|
||||
{props.children}
|
||||
<Dialog.Root open={openAddNodeModal && nodeType} onOpenChange={setOpenNodeModal}>
|
||||
<Dialog.Content maxWidth="450px">
|
||||
<Dialog.Title>New {nodeType?.type}</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
Add a new related {nodeType?.type}.
|
||||
</Dialog.Description>
|
||||
<form onSubmit={onSubmitNewNodeModal}>
|
||||
<Flex direction="column" gap="3">
|
||||
<label>
|
||||
<Text as="div" size="2" mb="1" weight="bold">
|
||||
Value
|
||||
</Text>
|
||||
<TextField.Root
|
||||
defaultValue={""}
|
||||
name={nodeType?.field}
|
||||
placeholder={`Your value here (${nodeType?.field})`}
|
||||
/>
|
||||
</label>
|
||||
</Flex>
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button loading={loading} type="submit">Save</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</NodeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNodeContext = (): NodeContextType => {
|
||||
const context = useContext(NodeContext);
|
||||
if (!context) {
|
||||
throw new Error("useNodeContext must be used within a NodeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
57
src/components/investigations/nodes/phone.tsx
Normal file
57
src/components/investigations/nodes/phone.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Card, Box, Text, ContextMenu, Flex, Inset, Badge } from '@radix-ui/themes';
|
||||
import { NodeProvider, useNodeContext } from './node-context';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
|
||||
function Custom({ data }: any) {
|
||||
const { handleDeleteNode } = useNodeContext()
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<Box>
|
||||
<Card>
|
||||
<Inset>
|
||||
<Flex className='items-center p-0'>
|
||||
<Badge className='!h-[24px] !rounded-r-none'>
|
||||
<PhoneIcon className='h-3 w-3' />
|
||||
</Badge>
|
||||
<Box className='p-1'>
|
||||
<Text as="div" size="1" weight="regular">
|
||||
{data.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Inset>
|
||||
</Card>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="w-16 !bg-teal-500"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item shortcut="⌘ C">Copy content</ContextMenu.Item>
|
||||
<ContextMenu.Item shortcut="⌘ D">Duplicate</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onClick={handleDeleteNode} shortcut="⌘ ⌫" color="red">
|
||||
Delete
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PhoneNode = (props: any) => (
|
||||
<NodeProvider>
|
||||
<Custom {...props} />
|
||||
</NodeProvider>
|
||||
);
|
||||
|
||||
export default memo(PhoneNode);
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
Navbar as HeroUINavbar,
|
||||
NavbarContent,
|
||||
NavbarMenu,
|
||||
NavbarMenuToggle,
|
||||
NavbarBrand,
|
||||
NavbarItem,
|
||||
NavbarMenuItem,
|
||||
} from "@heroui/navbar";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Kbd } from "@heroui/kbd";
|
||||
import { Link } from "@heroui/link";
|
||||
import { Input } from "@heroui/input";
|
||||
import { link as linkStyles } from "@heroui/theme";
|
||||
import NextLink from "next/link";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { ThemeSwitch } from "@/src/components/theme-switch";
|
||||
import {
|
||||
TwitterIcon,
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
SearchIcon,
|
||||
Logo,
|
||||
} from "@/src/components/icons";
|
||||
|
||||
export const Navbar = () => {
|
||||
const searchInput = (
|
||||
<Input
|
||||
aria-label="Search"
|
||||
classNames={{
|
||||
inputWrapper: "bg-default-100",
|
||||
input: "text-sm",
|
||||
}}
|
||||
endContent={
|
||||
<Kbd className="hidden lg:inline-block" keys={["command"]}>
|
||||
K
|
||||
</Kbd>
|
||||
}
|
||||
labelPlacement="outside"
|
||||
placeholder="Search..."
|
||||
startContent={
|
||||
<SearchIcon className="text-base text-default-400 pointer-events-none flex-shrink-0" />
|
||||
}
|
||||
type="search"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<HeroUINavbar isBordered maxWidth="xl" position="sticky" className="h-12">
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
|
||||
<NavbarBrand as="li" className="gap-3 max-w-fit">
|
||||
<NextLink className="flex justify-start items-center gap-1" href="/">
|
||||
<Logo />
|
||||
</NextLink>
|
||||
</NavbarBrand>
|
||||
<ul className="hidden lg:flex gap-4 justify-start ml-2">
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<NavbarItem key={item.href}>
|
||||
<NextLink
|
||||
className={clsx(
|
||||
linkStyles({ color: "foreground" }),
|
||||
"data-[active=true]:text-primary data-[active=true]:font-medium",
|
||||
)}
|
||||
color="foreground"
|
||||
href={item.href}
|
||||
>
|
||||
{item.label}
|
||||
</NextLink>
|
||||
</NavbarItem>
|
||||
))}
|
||||
</ul>
|
||||
</NavbarContent>
|
||||
|
||||
<NavbarContent
|
||||
className="hidden sm:flex basis-1/5 sm:basis-full"
|
||||
justify="end"
|
||||
>
|
||||
{/* <NavbarItem className="hidden lg:flex">{searchInput}</NavbarItem> */}
|
||||
<NavbarItem className="hidden sm:flex gap-2">
|
||||
<ThemeSwitch />
|
||||
</NavbarItem>
|
||||
</NavbarContent>
|
||||
|
||||
<NavbarContent className="sm:hidden basis-1 pl-4" justify="end">
|
||||
<Link isExternal aria-label="Github" href={siteConfig.links.github}>
|
||||
<GithubIcon className="text-default-500" />
|
||||
</Link>
|
||||
<ThemeSwitch />
|
||||
<NavbarMenuToggle />
|
||||
</NavbarContent>
|
||||
|
||||
<NavbarMenu>
|
||||
{searchInput}
|
||||
<div className="mx-4 mt-2 flex flex-col gap-2">
|
||||
{siteConfig.navMenuItems.map((item, index) => (
|
||||
<NavbarMenuItem key={`${item}-${index}`}>
|
||||
<Link
|
||||
color={
|
||||
index === 2
|
||||
? "primary"
|
||||
: index === siteConfig.navMenuItems.length - 1
|
||||
? "danger"
|
||||
: "foreground"
|
||||
}
|
||||
href="#"
|
||||
size="lg"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</NavbarMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</NavbarMenu>
|
||||
</HeroUINavbar>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { VisuallyHidden } from "@react-aria/visually-hidden";
|
||||
import { SwitchProps, useSwitch } from "@heroui/switch";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useIsSSR } from "@react-aria/ssr";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { SunFilledIcon, MoonFilledIcon } from "@/src/components/icons";
|
||||
import { SunIcon, MoonIcon } from "lucide-react";
|
||||
import { Switch } from "@radix-ui/themes";
|
||||
|
||||
export interface ThemeSwitchProps {
|
||||
className?: string;
|
||||
classNames?: SwitchProps["classNames"];
|
||||
}
|
||||
|
||||
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
className,
|
||||
classNames,
|
||||
export const ThemeSwitch = ({
|
||||
|
||||
}) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isSSR = useIsSSR();
|
||||
const isSRR = useIsSSR()
|
||||
|
||||
const onChange = () => {
|
||||
theme === "light" ? setTheme("dark") : setTheme("light");
|
||||
};
|
||||
|
||||
const {
|
||||
Component,
|
||||
slots,
|
||||
isSelected,
|
||||
getBaseProps,
|
||||
getInputProps,
|
||||
getWrapperProps,
|
||||
} = useSwitch({
|
||||
isSelected: theme === "light" || isSSR,
|
||||
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
|
||||
onChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...getBaseProps({
|
||||
className: clsx(
|
||||
"px-px transition-opacity hover:opacity-80 cursor-pointer",
|
||||
className,
|
||||
classNames?.base,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
{...getWrapperProps()}
|
||||
className={slots.wrapper({
|
||||
class: clsx(
|
||||
[
|
||||
"w-auto h-auto",
|
||||
"bg-transparent",
|
||||
"rounded-lg",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[selected=true]:bg-transparent",
|
||||
"!text-default-500",
|
||||
"pt-px",
|
||||
"px-0",
|
||||
"mx-0",
|
||||
],
|
||||
classNames?.wrapper,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{!isSelected || isSSR ? (
|
||||
<SunFilledIcon size={22} />
|
||||
) : (
|
||||
<MoonFilledIcon size={22} />
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
<div>
|
||||
{!isSRR && <Switch defaultChecked suppressHydrationWarning onCheckedChange={onChange} checked={theme === "light"} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,16 +38,15 @@ export async function getInvestigation(investigationId: string): Promise<ReturnT
|
||||
export async function getInvestigationData(investigationId: string): Promise<{ nodes: NodeData[], edges: EdgeData[] }> {
|
||||
const supabase = await createClient();
|
||||
let { data: individuals, error: indError } = await supabase
|
||||
.from('investigation_individuals')
|
||||
.select('individuals(*, ip_addresses(*), phone_numbers(*), social_accounts(*))')
|
||||
.from('individuals')
|
||||
.select('*, ip_addresses(*), phone_numbers(*), social_accounts(*), emails(*)')
|
||||
.eq('investigation_id', investigationId);
|
||||
if (indError) throw indError;
|
||||
if (!individuals) individuals = [];
|
||||
|
||||
// Extraire les IDs
|
||||
const individualIds = individuals
|
||||
// @ts-ignore
|
||||
.map((ind) => ind.individuals?.id)
|
||||
// @ts-ignore
|
||||
const individualIds = individuals.map((ind) => ind.id);
|
||||
|
||||
if (individualIds.length === 0) {
|
||||
return { nodes: [], edges: [] };
|
||||
@@ -61,20 +60,101 @@ export async function getInvestigationData(investigationId: string): Promise<{ n
|
||||
|
||||
if (relError) throw relError;
|
||||
if (!relations) relations = [];
|
||||
// Construire les nœuds
|
||||
const nodes: NodeData[] = individuals.map(({ individuals: ind }: any) => ({
|
||||
id: ind.id.toString(),
|
||||
type: 'custom',
|
||||
data: { ...ind, label: ind.full_name },
|
||||
position: { x: 0, y: 100 }
|
||||
|
||||
}));
|
||||
const nodes: NodeData[] = [];
|
||||
const edges: EdgeData[] = [];
|
||||
|
||||
// Construire les nœuds des individus
|
||||
individuals.forEach((ind: any) => {
|
||||
const individualId = ind.id.toString();
|
||||
nodes.push({
|
||||
id: individualId,
|
||||
type: 'individual',
|
||||
data: { ...ind, label: ind.full_name },
|
||||
position: { x: 0, y: 100 }
|
||||
});
|
||||
|
||||
// Ajouter les emails
|
||||
ind.emails?.forEach((email: any) => {
|
||||
nodes.push({
|
||||
id: email.id.toString(),
|
||||
type: 'email',
|
||||
data: { ...email, label: email.email },
|
||||
position: { x: 100, y: 100 }
|
||||
});
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: email.id.toString(),
|
||||
type: 'custom',
|
||||
id: `${individualId}-${email.id}`.toString(),
|
||||
label: 'email',
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les numéros de téléphone
|
||||
ind.phone_numbers?.forEach((phone: any) => {
|
||||
nodes.push({
|
||||
id: phone.id.toString(),
|
||||
type: 'phone',
|
||||
data: { ...phone, label: phone.phone_number },
|
||||
position: { x: -100, y: 100 }
|
||||
});
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: phone.id.toString(),
|
||||
type: 'custom',
|
||||
id: `${individualId}-${phone.id}`.toString(),
|
||||
label: 'phone',
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les comptes sociaux
|
||||
ind.social_accounts?.forEach((social: any) => {
|
||||
nodes.push({
|
||||
id: social.id.toString(),
|
||||
type: 'social',
|
||||
data: { ...social, label: `${social.platform}: ${social.username}` },
|
||||
position: { x: 100, y: -100 }
|
||||
});
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: social.id.toString(),
|
||||
type: 'custom',
|
||||
id: `${individualId}-${social.id}`.toString(),
|
||||
label: 'social',
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les adresses IP
|
||||
ind.ip_addresses?.forEach((ip: any) => {
|
||||
nodes.push({
|
||||
id: ip.id.toString(),
|
||||
type: 'ip',
|
||||
data: { label: ip.ip_address },
|
||||
position: { x: -100, y: -100 }
|
||||
});
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: ip.id.toString(),
|
||||
type: 'custom',
|
||||
id: `${individualId}-${ip.id}`.toString(),
|
||||
label: 'IP',
|
||||
});
|
||||
});
|
||||
});
|
||||
relations.forEach(({ individual_a, individual_b, relation_type, confidence_level }) => {
|
||||
edges.push({
|
||||
source: individual_a.toString(),
|
||||
target: individual_b.toString(),
|
||||
type: 'custom',
|
||||
id: `${individual_a}-${individual_b}`.toString(),
|
||||
label: relation_type,
|
||||
confidence_level: confidence_level
|
||||
});
|
||||
});
|
||||
|
||||
// Construire les arêtes
|
||||
const edges: EdgeData[] = relations.flatMap(({ individual_a, individual_b, relation_type, confidence_level }) => [
|
||||
{ source: individual_a.toString(), target: individual_b.toString(), type: 'custom', id: `${individual_a}-${individual_b}`.toString(), label: relation_type, confidence_level: confidence_level },
|
||||
]);
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
|
||||
@@ -7,7 +7,8 @@ export type IconSvgProps = SVGProps<SVGSVGElement> & {
|
||||
export type NodeData = {
|
||||
id: string;
|
||||
position: any,
|
||||
data: any
|
||||
type: string,
|
||||
data: any,
|
||||
};
|
||||
|
||||
export type EdgeData = {
|
||||
@@ -15,6 +16,8 @@ export type EdgeData = {
|
||||
target: string;
|
||||
id: string;
|
||||
label: string;
|
||||
type: string,
|
||||
confidence_level?: number | string
|
||||
};
|
||||
|
||||
export type InvestigationGraph = {
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
/* @import url('./xy-theme.css'); */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@@ -1,5 +1,3 @@
|
||||
import { heroui } from "@heroui/theme"
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
@@ -17,5 +15,4 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
darkMode: "class",
|
||||
plugins: [heroui()],
|
||||
}
|
||||
|
||||
30
utils.ts
30
utils.ts
@@ -1,36 +1,6 @@
|
||||
import { JSONContent } from "@tiptap/react"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function linkifyText(text: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
return text.split(urlRegex).map((part) => {
|
||||
if (part.match(urlRegex)) {
|
||||
return `<a href="${part}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">${part}</a>`;
|
||||
}
|
||||
return part;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function extractTitleAndDescription(json: JSONContent) {
|
||||
let title = '';
|
||||
let description = '';
|
||||
|
||||
if (json.type === 'doc' && json.content) {
|
||||
const headingNode = json.content.find(node => node.type === 'heading' && node.attrs?.level === 1);
|
||||
if (headingNode && headingNode.content) {
|
||||
title = headingNode.content.map(contentNode => contentNode.text || '').join('');
|
||||
}
|
||||
const paragraphNode = json.content.find(node => node.type === 'paragraph');
|
||||
if (paragraphNode && paragraphNode.content) {
|
||||
description = paragraphNode.content.map(contentNode => contentNode.text || '').join('');
|
||||
}
|
||||
}
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
export const priorities = ["low", "medium", "high"]
|
||||
|
||||
Reference in New Issue
Block a user