feat: custom nodes

This commit is contained in:
dextmorgn
2025-02-04 00:22:59 +01:00
parent d743b220d2
commit d8ef2d3c9a
38 changed files with 1969 additions and 1507 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -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"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
postcss.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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&nbsp;</span>
<span className={title({ color: "violet" })}>beautiful&nbsp;</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>
);

View File

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

View File

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

View File

@@ -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>
)
}

View File

@@ -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 >
</>
);
}

View File

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

View File

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

View File

@@ -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>
</>
);
}

View File

@@ -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);

View File

@@ -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>

View File

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

View File

@@ -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>
)

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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 };
}

View File

@@ -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() {

View File

@@ -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 = {

View File

@@ -1,5 +1 @@
/* @import url('./xy-theme.css'); */
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";

View File

@@ -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()],
}

View File

@@ -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"]

1537
yarn.lock

File diff suppressed because it is too large Load Diff