mirror of
https://github.com/reconurge/flowsint.git
synced 2026-04-30 19:29:26 -05:00
feat: resizable panels
This commit is contained in:
@@ -26,12 +26,15 @@
|
||||
"d3-quadtree": "^3.0.1",
|
||||
"framer-motion": "^12.3.1",
|
||||
"intl-messageformat": "^10.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"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",
|
||||
"react-highlight-words": "^0.21.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"swr": "^2.3.0",
|
||||
"tailwindcss": "^4.0.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
|
||||
@@ -5,15 +5,16 @@ import { useIndividuals } from '@/src/lib/hooks/individuals/use-individuals';
|
||||
import { useInvestigationContext } from '@/src/components/contexts/investigation-provider';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { Pencil1Icon } from '@radix-ui/react-icons';
|
||||
import { RotateCwIcon } from 'lucide-react';
|
||||
|
||||
const Filters = ({ investigation_id }: { investigation_id: string }) => {
|
||||
const { individuals, isLoading } = useIndividuals(investigation_id)
|
||||
const { individuals, isLoading, refetch } = useIndividuals(investigation_id)
|
||||
const { currentNode, setCurrentNode, handleOpenIndividualModal } = useInvestigationContext()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Text size={"2"}>Profiles</Text>
|
||||
<Flex direction={"column"} gap="3">
|
||||
<Flex justify={"between"} align={"center"}><Text size={"2"}>Profiles</Text> <IconButton disabled={isLoading} onClick={() => refetch()} size={"1"} variant='ghost' color='gray'><RotateCwIcon className={cn('h-3.5', isLoading && 'animate-spin')} /></IconButton></Flex>
|
||||
<Flex direction={"column"} gap="1">
|
||||
{isLoading && <>
|
||||
<Skeleton height={"48px"} />
|
||||
<Skeleton height={"48px"} />
|
||||
@@ -21,7 +22,7 @@ const Filters = ({ investigation_id }: { investigation_id: string }) => {
|
||||
</>
|
||||
}
|
||||
{individuals?.map((individual: any) => (
|
||||
<Box key={individual.id} maxWidth="240px">
|
||||
<Box key={individual.id}>
|
||||
<Card className={cn('relative group cursor-pointer border border-transparent hover:border-sky-400', currentNode === individual.id && 'border-sky-400')} onClick={() => setCurrentNode(individual.id)}>
|
||||
<Flex gap="3" align="center">
|
||||
<Avatar
|
||||
|
||||
@@ -26,7 +26,7 @@ const DashboardLayout = async ({
|
||||
return (
|
||||
<InvestigationProvider>
|
||||
<SearchProvider>
|
||||
<InvestigationLayout left={<Individuals investigation_id={investigation_id} />}>
|
||||
<InvestigationLayout investigation_id={investigation_id} left={<Individuals investigation_id={investigation_id} />}>
|
||||
{children}
|
||||
</InvestigationLayout>
|
||||
</SearchProvider>
|
||||
|
||||
128
src/components/dashboard/seach-palette.tsx
Normal file
128
src/components/dashboard/seach-palette.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client"
|
||||
import { useState, useCallback, useEffect, SetStateAction } from 'react'
|
||||
// @ts-ignore
|
||||
import debounce from "lodash.debounce";
|
||||
import { SearchIcon } from "lucide-react"
|
||||
// @ts-ignore
|
||||
import Highlighter from "react-highlight-words";
|
||||
import Link from 'next/link';
|
||||
import { useSearchResults } from '../../lib/hooks/investigation/use-search-results';
|
||||
import { Card, Dialog, IconButton, TextField } from '@radix-ui/themes';
|
||||
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
|
||||
import { useInvestigationContext } from '../contexts/investigation-provider';
|
||||
|
||||
const SearchModal = ({ investigation_id }: { investigation_id: string }) => {
|
||||
const [search, setSearch] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
const { setCurrentNode } = useInvestigationContext()
|
||||
const {
|
||||
results,
|
||||
error,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useSearchResults(search, investigation_id);
|
||||
|
||||
const changeHandler = (event: { target: { value: SetStateAction<string>; }; }) => {
|
||||
setSearch(event.target.value);
|
||||
refetch && refetch();
|
||||
};
|
||||
const debouncedChangeHandler = useCallback(debounce(changeHandler, 300), []);
|
||||
|
||||
const handleClose = () => {
|
||||
() => setSearch('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: { ctrlKey: any; key: string; }) => {
|
||||
if (event.ctrlKey && event.key === 'k') {
|
||||
setOpen(true)
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const SearchItem = ({ item }: any) =>
|
||||
(<li onClick={() => setOpen(false)}>
|
||||
<Card className='cursor-pointer hover:border-sky-400 border border-transparent' onClick={() => setCurrentNode(item.id)}>
|
||||
<span className='flex flex-col gap-1 text-left'>
|
||||
<span className='text-xs opacity-50'>Individual</span>
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='truncate text-ellipsis'>
|
||||
<Highlighter
|
||||
searchWords={search.split(" ")}
|
||||
autoEscape={true}
|
||||
textToHighlight={item.full_name}
|
||||
/>
|
||||
</span>
|
||||
<span className='truncate text-ellipsis text-sm opacity-75'>
|
||||
<Highlighter
|
||||
searchWords={search.split(" ")}
|
||||
autoEscape={true}
|
||||
textToHighlight={item.notes}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{/* <span className='text-sm opacity-75'>
|
||||
<Highlighter
|
||||
searchWords={search.split(" ")}
|
||||
autoEscape={true}
|
||||
textToHighlight={item.netname}
|
||||
/>
|
||||
</span> */}
|
||||
</span>
|
||||
</Card>
|
||||
</li>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
|
||||
<Dialog.Root open={open} onOpenChange={handleClose}>
|
||||
<IconButton onClick={() => setOpen(true)} color="gray" size="2" variant="soft">
|
||||
<SearchIcon className="h-4" />
|
||||
</IconButton>
|
||||
<Dialog.Content maxWidth="450px">
|
||||
<Dialog.Title>Search</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
Find the profile you're looking for.
|
||||
</Dialog.Description>
|
||||
|
||||
<TextField.Root
|
||||
defaultValue={search}
|
||||
onChange={debouncedChangeHandler}
|
||||
placeholder="Search the docs…">
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
<div className='min-h-[20vh] max-h-[60vh] w-full relative text-center flex flex-col items-center justify-center gap-2'>
|
||||
{error && "An error occured."}
|
||||
{isLoading && <span className="loading loading-bars loading-md"></span>}
|
||||
{results?.length === 0 && `No results found for "${search}".`}
|
||||
<ul className='w-full h-full flex flex-col gap-1 overflow-auto mt-2'>{!error && !isLoading && Array.isArray(results) && results?.map((item) => (
|
||||
<SearchItem key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
{search === '' && (
|
||||
<div className="px-6 py-14 text-center flex items-center justify-center flex-col text-sm sm:px-14">
|
||||
<SearchIcon
|
||||
className="h-12 w-12 opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 font-semibold">Search for ip_addresses, domain names and other</p>
|
||||
<p className="mt-2 opacity-60">
|
||||
Put you search between quotes for an exact match search.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchModal
|
||||
@@ -235,8 +235,9 @@ const LayoutFlow = ({ initialNodes, initialEdges, theme }: { initialNodes: any,
|
||||
if (currentNode) {
|
||||
const internalNode = getNode(currentNode)
|
||||
if (!internalNode) return
|
||||
updateNode(internalNode.id, { ...internalNode, zIndex: 2000, style: { ...internalNode.style, opacity: 1 } })
|
||||
setCenter(internalNode?.position.x + 100, internalNode?.position.y, { duration: 1000, zoom: 1.5 })
|
||||
updateNode(internalNode.id, { ...internalNode, zIndex: 5000, style: { ...internalNode.style, opacity: 1 } })
|
||||
//@ts-ignore
|
||||
setCenter(internalNode?.position.x + (internalNode?.measured?.width / 2), internalNode?.position.y + (internalNode?.measured?.height / 2) + 20, { duration: 1000, zoom: 1.5 })
|
||||
highlightPath(internalNode);
|
||||
}
|
||||
}, [currentNode, highlightPath, setCenter, resetNodeStyles]);
|
||||
@@ -265,6 +266,7 @@ const LayoutFlow = ({ initialNodes, initialEdges, theme }: { initialNodes: any,
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
minZoom={0.1}
|
||||
// edgesUpdatable={!isLocked}
|
||||
// edgesFocusable={!isLocked}
|
||||
// nodesDraggable={!isLocked}
|
||||
|
||||
@@ -5,35 +5,47 @@ import { ThemeSwitch } from '../theme-switch';
|
||||
import MoreMenu from './more-menu';
|
||||
import CaseSelector from './case-selector';
|
||||
import NewCase from '../dashboard/new-case';
|
||||
import SearchModal from '../dashboard/seach-palette';
|
||||
import { ScrollArea } from '@radix-ui/themes';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
|
||||
const InvestigationLayout = ({
|
||||
children,
|
||||
left
|
||||
left,
|
||||
investigation_id
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
left: React.ReactNode;
|
||||
investigation_id: string
|
||||
}) => {
|
||||
return (
|
||||
<div className='h-screen w-screen flex'>
|
||||
<div className='flex'>
|
||||
<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'>
|
||||
<PanelGroup className='h-screen w-screen flex' direction="horizontal">
|
||||
<Panel className='h-screen' defaultSize={20} minSize={20}>
|
||||
<div className='flex flex-col w-full h-full 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 gap-1 flex-row justify-end p-2'>
|
||||
<SearchModal investigation_id={investigation_id} />
|
||||
<NewCase />
|
||||
</div>
|
||||
<div className='p-3 grow overflow-y-auto'>
|
||||
<ScrollArea type="auto" scrollbars="vertical" className='p-3 h-full grow overflow-y-auto'>
|
||||
<div className="flex flex-col">
|
||||
{left}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow flex flex-col'>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel defaultSize={80} minSize={50} className='grow flex flex-col'>
|
||||
<div>
|
||||
<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 />
|
||||
</div>
|
||||
{children}
|
||||
</div></div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function Custom(props: any) {
|
||||
</Flex>
|
||||
</Card> :
|
||||
<Tooltip content={data.full_name}>
|
||||
<button onDoubleClick={() => handleOpenIndividualModal(data.id)} className='!rounded-full border-transparent'>
|
||||
<button onDoubleClick={() => handleOpenIndividualModal(data.id)} className={cn('rounded-full border border-transparent hover:border-sky-400', currentNode === data.id && "border-sky-400")}>
|
||||
<Avatar
|
||||
size="3"
|
||||
src={data?.image_url}
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function login(formData: FormData) {
|
||||
const { error } = await supabase.auth.signInWithPassword(data)
|
||||
|
||||
if (error) {
|
||||
console.log(error)
|
||||
redirect('/error')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export function useEmailsAndBreaches(individualId: string | null | undefined) {
|
||||
revalidateOnReconnect: false,
|
||||
},
|
||||
)
|
||||
console.log(emails)
|
||||
|
||||
const emailAddresses = emails?.emails?.map(({ email }: { email: string }) => email) || []
|
||||
|
||||
|
||||
19
src/lib/hooks/investigation/use-search-results.tsx
Normal file
19
src/lib/hooks/investigation/use-search-results.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { supabase } from "@/src/lib/supabase/client";
|
||||
import { useQuery } from "@supabase-cache-helpers/postgrest-swr";
|
||||
|
||||
export function useSearchResults(search: string | undefined, investigationId: string | string[] | undefined) {
|
||||
const { data: results, count, mutate, isLoading, error } = useQuery(search ?
|
||||
supabase
|
||||
.from('individuals')
|
||||
.select('*')
|
||||
.eq("investigation_id", investigationId)
|
||||
.ilike('full_name', `%${search}%`) : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
if (error) throw error
|
||||
|
||||
return { results, count, isLoading, refetch: mutate, error };
|
||||
}
|
||||
23
yarn.lock
23
yarn.lock
@@ -2866,6 +2866,11 @@ hasown@^2.0.0, hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
highlight-words-core@^1.2.0:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.3.tgz#781f37b2a220bf998114e4ef8c8cb6c7a4802ea8"
|
||||
integrity sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ==
|
||||
|
||||
ignore@^5.1.1, ignore@^5.2.0, ignore@^5.3.1:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
@@ -3315,6 +3320,11 @@ math-intrinsics@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
memoize-one@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.1.0.tgz#a2387c58c03fff27ca390c31b764a79addf3f906"
|
||||
integrity sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA==
|
||||
|
||||
merge-anything@5.1.7:
|
||||
version "5.1.7"
|
||||
resolved "https://registry.yarnpkg.com/merge-anything/-/merge-anything-5.1.7.tgz#94f364d2b0cf21ac76067b5120e429353b3525d7"
|
||||
@@ -3700,6 +3710,14 @@ react-dom@^19.0.0:
|
||||
dependencies:
|
||||
scheduler "^0.25.0"
|
||||
|
||||
react-highlight-words@^0.21.0:
|
||||
version "0.21.0"
|
||||
resolved "https://registry.yarnpkg.com/react-highlight-words/-/react-highlight-words-0.21.0.tgz#a109acdf7dc6fac3ed7db82e9cba94e8d65c281c"
|
||||
integrity sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ==
|
||||
dependencies:
|
||||
highlight-words-core "^1.2.0"
|
||||
memoize-one "^4.0.0"
|
||||
|
||||
react-is@^16.13.1:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
@@ -3724,6 +3742,11 @@ react-remove-scroll@^2.6.3:
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-resizable-panels@^2.1.7:
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz#afd29d8a3d708786a9f95183a38803c89f13c2e7"
|
||||
integrity sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==
|
||||
|
||||
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
|
||||
|
||||
Reference in New Issue
Block a user