feat: resizable panels

This commit is contained in:
dextmorgn
2025-02-09 18:30:24 +01:00
parent 45ba3b91c2
commit 91af724e19
11 changed files with 210 additions and 24 deletions

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,6 @@ export async function login(formData: FormData) {
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
console.log(error)
redirect('/error')
}

View File

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

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

View File

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