1+ import { useState , useEffect } from 'react'
12import Head from 'next/head'
23import useSWR from 'swr'
34import Link from 'next/link'
4- import { ExternalLink } from 'lucide-react'
5+ import { useRouter } from 'next/router'
6+ import { ExternalLink , Search , X , ChevronDown } from 'lucide-react'
57import { fetcher } from '@/lib/fetcher'
6- import { extractPackageName } from '@/lib/utils'
8+ import { extractPackageName , cn } from '@/lib/utils'
79import { PackageInspectResult } from '@/components/package-inspector/types'
810import PackageHeroCard from '@/components/package-inspector/PackageHeroCard'
911import ScoreCardChart from '@/components/package-inspector/ScoreCardChart'
@@ -12,11 +14,77 @@ import { Button } from '@/components/ui/button'
1214import { Skeleton } from '@/components/ui/skeleton'
1315import { Container } from '../ui/container'
1416
17+ const ECOSYSTEMS = [
18+ { label : 'Alpine' , value : 'alpine' } ,
19+ { label : 'Bitnami' , value : 'bitnami' } ,
20+ { label : 'Crates.io' , value : 'crates.io' } ,
21+ { label : 'Debian' , value : 'debian' } ,
22+ { label : 'Git' , value : 'git' } ,
23+ { label : 'Go' , value : 'go' } ,
24+ { label : 'Hex' , value : 'hex' } ,
25+ { label : 'Maven' , value : 'maven' } ,
26+ { label : 'npm' , value : 'npm' } ,
27+ { label : 'NuGet' , value : 'nuget' } ,
28+ { label : 'Opam' , value : 'opam' } ,
29+ { label : 'OSS-Fuzz' , value : 'oss-fuzz' } ,
30+ { label : 'Packagist' , value : 'packagist' } ,
31+ { label : 'Pub' , value : 'pub' } ,
32+ { label : 'Red-Hat' , value : 'red-hat' } ,
33+ { label : 'Rubygems' , value : 'rubygems' } ,
34+ ]
35+
1536export default function PurlPageComponent ( { purl } : { purl ?: string } ) {
37+ const router = useRouter ( )
1638 const purlString = typeof purl === 'string' ? purl : ''
1739 const decodedPurl = purlString ? decodeURIComponent ( purlString ) : ''
1840 const packageName = decodedPurl ? extractPackageName ( decodedPurl ) : ''
1941
42+ const [ searchOpen , setSearchOpen ] = useState ( false )
43+ const [ ecosystem , setEcosystem ] = useState ( 'npm' )
44+ const [ searchName , setSearchName ] = useState ( '' )
45+ const [ version , setVersion ] = useState ( '' )
46+ const [ searchError , setSearchError ] = useState < string | null > ( null )
47+ const [ isSearching , setIsSearching ] = useState ( false )
48+
49+ useEffect ( ( ) => {
50+ setSearchOpen ( false )
51+ setSearchName ( '' )
52+ setVersion ( '' )
53+ setSearchError ( null )
54+ setIsSearching ( false )
55+ } , [ purl ] )
56+
57+ const handleSearchSubmit = async ( ) => {
58+ setSearchError ( null )
59+ if ( ! searchName . trim ( ) || ! version . trim ( ) ) {
60+ setSearchError ( 'Please fill in both fields.' )
61+ return
62+ }
63+ const versionPart = version . trim ( ) ? `@${ version . trim ( ) } ` : ''
64+ const newPurl = `pkg:${ ecosystem } /${ searchName . trim ( ) } ${ versionPart } `
65+ setIsSearching ( true )
66+ try {
67+ const response = await fetch (
68+ 'https://api.main.devguard.org/api/v1/vulndb/purl-inspect/' +
69+ encodeURIComponent ( newPurl ) ,
70+ )
71+ if ( ! response . ok ) {
72+ setSearchError ( 'Package not found. Please check your input.' )
73+ return
74+ }
75+ const data = await response . json ( )
76+ if ( ! data ?. component ) {
77+ setSearchError ( 'Package not found. Please check your input.' )
78+ return
79+ }
80+ router . push ( '/package-inspector/' + encodeURIComponent ( newPurl ) )
81+ } catch {
82+ setSearchError ( 'Could not reach the API. Please try again.' )
83+ } finally {
84+ setIsSearching ( false )
85+ }
86+ }
87+
2088 const url = decodedPurl
2189 ? 'https://api.main.devguard.org/api/v1/vulndb/purl-inspect/' +
2290 decodedPurl
@@ -100,10 +168,89 @@ export default function PurlPageComponent({ purl }: { purl?: string }) {
100168 </ Head >
101169
102170 { /* Header */ }
103- < div className = "mb-6" >
171+ < div className = "mb-6 flex items-end justify-between " >
104172 < p className = "mb-1 text-sm font-medium uppercase tracking-wider text-gray-500" >
105173 Package Inspector
106174 </ p >
175+ < div className = "relative" >
176+ { ! searchOpen ? (
177+ < button
178+ type = "button"
179+ onClick = { ( ) => setSearchOpen ( true ) }
180+ className = "flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/50 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:border-gray-600 hover:text-gray-300"
181+ >
182+ < Search className = "h-3.5 w-3.5" />
183+ Search again
184+ </ button >
185+ ) : (
186+ < form
187+ onSubmit = { ( e ) => {
188+ e . preventDefault ( )
189+ handleSearchSubmit ( )
190+ } }
191+ className = "flex flex-col items-end gap-2"
192+ >
193+ < div
194+ className = { cn (
195+ 'flex items-center rounded-xl border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50' ,
196+ searchError ? 'border-red-500 focus-within:border-red-500 focus-within:ring-red-500/50' : '' ,
197+ ) }
198+ >
199+ < div className = "relative flex items-center" >
200+ < select
201+ value = { ecosystem }
202+ onChange = { ( e ) => setEcosystem ( e . target . value ) }
203+ className = "h-9 appearance-none bg-transparent pl-2.5 pr-6 text-xs outline-none rounded-l-xl cursor-pointer"
204+ disabled = { isSearching }
205+ >
206+ { ECOSYSTEMS . map ( ( eco ) => (
207+ < option key = { eco . value } value = { eco . value } >
208+ { eco . label }
209+ </ option >
210+ ) ) }
211+ </ select >
212+ < ChevronDown className = "pointer-events-none absolute right-1 h-3 w-3 text-muted-foreground" />
213+ </ div >
214+ < div className = "h-6 w-px bg-input" />
215+ < input
216+ type = "text"
217+ value = { searchName }
218+ onChange = { ( e ) => setSearchName ( e . target . value ) }
219+ placeholder = "name"
220+ className = "h-9 w-24 min-w-0 bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
221+ disabled = { isSearching }
222+ autoFocus
223+ />
224+ < div className = "h-6 w-px bg-input" />
225+ < input
226+ type = "text"
227+ value = { version }
228+ onChange = { ( e ) => {
229+ setVersion ( e . target . value )
230+ setSearchError ( null )
231+ } }
232+ placeholder = "version"
233+ className = "h-9 w-20 bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
234+ disabled = { isSearching }
235+ />
236+ < button
237+ type = "button"
238+ onClick = { ( ) => {
239+ setSearchOpen ( false )
240+ setSearchError ( null )
241+ } }
242+ className = "mr-1.5 flex items-center text-gray-500 hover:text-gray-300"
243+ >
244+ < X className = "h-3.5 w-3.5" />
245+ </ button >
246+ </ div >
247+ < button type = "submit" className = "hidden" />
248+ { searchError && (
249+ < span className = "text-xs text-red-400" > { searchError } </ span >
250+ ) }
251+ </ form >
252+ ) }
253+ </ div >
107254 </ div >
108255
109256 < PackageHeroCard
0 commit comments