@@ -2,6 +2,7 @@ import * as React from 'react'
2
2
import { createFileRoute , Link } from '@tanstack/react-router'
3
3
import { z } from 'zod'
4
4
import {
5
+ MdArrowBack ,
5
6
MdClose ,
6
7
MdLock ,
7
8
MdLockOpen ,
@@ -13,6 +14,8 @@ import * as Plot from '@observablehq/plot'
13
14
import { ParentSize } from '@visx/responsive'
14
15
import { Tooltip } from '~/components/Tooltip'
15
16
import * as d3 from 'd3'
17
+ import { useCombobox } from 'downshift'
18
+ import { FaAngleRight , FaArrowLeft } from 'react-icons/fa'
16
19
17
20
type NpmStats = {
18
21
start : string
@@ -46,6 +49,15 @@ type TimeInterval = '7-days' | '30-days' | '90-days' | '180-days' | '365-days'
46
49
47
50
type BinningOption = 'monthly' | 'weekly' | 'daily'
48
51
52
+ type NpmPackage = {
53
+ name : string
54
+ description : string
55
+ version : string
56
+ publisher : {
57
+ username : string
58
+ }
59
+ }
60
+
49
61
function npmQueryOptions ( {
50
62
packageNames,
51
63
interval,
@@ -180,6 +192,7 @@ function NpmStatsChart({
180
192
181
193
// Compare dates at the start of the day
182
194
cutoffDate . setHours ( 0 , 0 , 0 , 0 )
195
+
183
196
return {
184
197
...stat ,
185
198
downloads : stat . downloads . filter ( ( d ) => {
@@ -286,8 +299,8 @@ function NpmStatsChart({
286
299
{ ( { width } ) => (
287
300
< PlotFigure
288
301
options = { {
289
- marginLeft : 50 ,
290
- marginRight : 0 ,
302
+ marginLeft : 70 ,
303
+ marginRight : 10 ,
291
304
marginBottom : 70 ,
292
305
width,
293
306
height,
@@ -428,6 +441,110 @@ export const Route = createFileRoute('/stats/npm/')({
428
441
component : RouteComponent ,
429
442
} )
430
443
444
+ function PackageSearch ( ) {
445
+ const [ items , setItems ] = React . useState < NpmPackage [ ] > ( [ ] )
446
+ const [ isLoading , setIsLoading ] = React . useState ( false )
447
+ const navigate = Route . useNavigate ( )
448
+
449
+ const {
450
+ isOpen,
451
+ getMenuProps,
452
+ getInputProps,
453
+ highlightedIndex,
454
+ getItemProps,
455
+ reset,
456
+ inputValue,
457
+ } = useCombobox ( {
458
+ items,
459
+ onInputValueChange : ( { inputValue } ) => {
460
+ if ( inputValue && inputValue . length > 2 ) {
461
+ setIsLoading ( true )
462
+ fetch (
463
+ `https://api.npms.io/v2/search?q=${ encodeURIComponent (
464
+ inputValue
465
+ ) } &size=10`
466
+ )
467
+ . then ( ( res ) => res . json ( ) )
468
+ . then ( ( data ) => {
469
+ const hasInputValue = data . results . find (
470
+ ( r : any ) => r . package . name === inputValue
471
+ )
472
+
473
+ setItems ( [
474
+ ...( hasInputValue ? [ ] : [ { name : inputValue } ] ) ,
475
+ ...data . results . map ( ( r : any ) => r . package ) ,
476
+ ] )
477
+ setIsLoading ( false )
478
+ } )
479
+ . catch ( ( ) => {
480
+ setIsLoading ( false )
481
+ } )
482
+ } else {
483
+ setItems ( [ ] )
484
+ }
485
+ } ,
486
+ onSelectedItemChange : ( { selectedItem } ) => {
487
+ if ( ! selectedItem ) return
488
+
489
+ navigate ( {
490
+ to : '.' ,
491
+ search : ( prev ) => ( {
492
+ ...prev ,
493
+ packageNames : [ ...( prev . packageNames || [ ] ) , selectedItem . name ] ,
494
+ } ) ,
495
+ resetScroll : false ,
496
+ } )
497
+ reset ( )
498
+ setItems ( [ ] )
499
+ } ,
500
+ } )
501
+
502
+ return (
503
+ < div className = "flex-1" >
504
+ < div className = "relative" >
505
+ < input
506
+ { ...getInputProps ( ) }
507
+ placeholder = "Search for a package..."
508
+ className = "w-full bg-gray-500/10 rounded-md px-3 py-2"
509
+ />
510
+ < ul
511
+ { ...getMenuProps ( ) }
512
+ className = { `absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 overflow-auto ${
513
+ isOpen ? '' : 'hidden'
514
+ } `}
515
+ >
516
+ { isLoading ? (
517
+ < li className = "px-3 py-2 text-gray-500" > Loading...</ li >
518
+ ) : items . length === 0 ? (
519
+ < li className = "px-3 py-2 text-gray-500" > No packages found</ li >
520
+ ) : (
521
+ items . map ( ( item , index ) => (
522
+ < li
523
+ key = { item . name }
524
+ { ...getItemProps ( { item, index } ) }
525
+ className = { `px-3 py-2 cursor-pointer ${
526
+ highlightedIndex === index
527
+ ? 'bg-gray-500/20 '
528
+ : 'hover:bg-gray-500/20'
529
+ } `}
530
+ >
531
+ < div className = "font-medium" > { item . name } </ div >
532
+ < div className = "text-sm text-gray-500 dark:text-gray-400" >
533
+ { item . description }
534
+ </ div >
535
+ < div className = "text-xs text-gray-400 dark:text-gray-500" >
536
+ { item . version ? `v${ item . version } • ` : '' }
537
+ { item . publisher ?. username }
538
+ </ div >
539
+ </ li >
540
+ ) )
541
+ ) }
542
+ </ ul >
543
+ </ div >
544
+ </ div >
545
+ )
546
+ }
547
+
431
548
function RouteComponent ( ) {
432
549
const {
433
550
packageNames,
@@ -436,7 +553,6 @@ function RouteComponent() {
436
553
viewMode = 'absolute' ,
437
554
binningOption : binningOptionParam ,
438
555
} = Route . useSearch ( )
439
- const [ searchValue , setSearchValue ] = React . useState ( '' )
440
556
const [ hiddenPackages , setHiddenPackages ] = React . useState < Set < string > > (
441
557
new Set ( )
442
558
)
@@ -478,34 +594,22 @@ function RouteComponent() {
478
594
} )
479
595
)
480
596
481
- const handleSubmit = ( e : React . FormEvent < HTMLFormElement > ) => {
482
- e . preventDefault ( )
483
- if ( ! searchValue ) return
484
-
485
- setSearchValue ( '' )
486
- navigate ( {
487
- to : '.' ,
488
- search : ( prev ) => ( {
489
- ...prev ,
490
- packageNames : [ ...( prev . packageNames || [ ] ) , searchValue ] ,
491
- } ) ,
492
- } )
493
- }
494
-
495
597
const removePackageName = ( packageName : string ) => {
496
598
navigate ( {
497
599
to : '.' ,
498
600
search : ( prev ) => ( {
499
601
...prev ,
500
602
packageNames : prev . packageNames ?. filter ( ( name ) => name !== packageName ) ,
501
603
} ) ,
604
+ resetScroll : false ,
502
605
} )
503
606
}
504
607
505
608
const setBinningOption = ( newBinningOption : BinningOption ) => {
506
609
navigate ( {
507
610
to : '.' ,
508
611
search : ( prev ) => ( { ...prev , binningOption : newBinningOption } ) ,
612
+ resetScroll : false ,
509
613
} )
510
614
}
511
615
@@ -535,6 +639,7 @@ function RouteComponent() {
535
639
...prev ,
536
640
interval : newInterval ,
537
641
} ) ,
642
+ resetScroll : false ,
538
643
} )
539
644
}
540
645
@@ -545,6 +650,7 @@ function RouteComponent() {
545
650
...prev ,
546
651
viewMode : mode ,
547
652
} ) ,
653
+ resetScroll : false ,
548
654
} )
549
655
}
550
656
@@ -555,6 +661,7 @@ function RouteComponent() {
555
661
...prev ,
556
662
binningOption : value ,
557
663
} ) ,
664
+ resetScroll : false ,
558
665
} )
559
666
}
560
667
@@ -565,6 +672,7 @@ function RouteComponent() {
565
672
...prev ,
566
673
baseline : prev . baseline === packageName ? undefined : packageName ,
567
674
} ) ,
675
+ resetScroll : false ,
568
676
} )
569
677
}
570
678
@@ -575,22 +683,18 @@ function RouteComponent() {
575
683
576
684
return (
577
685
< div className = "min-h-dvh p-4 space-y-4" >
578
- < div className = "bg-white dark:bg-black/50 rounded-lg p-4" >
579
- < Link to = "." >
580
- < h1 className = "text-3xl font-bold" > NPM Stats</ h1 >
686
+ < div className = "bg-white dark:bg-black/50 rounded-lg p-4 flex items-center gap-2 text-xl" >
687
+ < Link to = "/" className = "hover:text-blue-500" >
688
+ Home
689
+ </ Link >
690
+ < FaAngleRight />
691
+ < Link to = "." className = "hover:text-blue-500" >
692
+ NPM Stats
581
693
</ Link >
582
694
</ div >
583
695
< div className = "bg-white dark:bg-black/50 rounded-lg space-y-4 p-4" >
584
696
< div className = "flex gap-4 flex-wrap" >
585
- < form className = "flex gap-2 flex-1" onSubmit = { handleSubmit } >
586
- < input
587
- type = "text"
588
- value = { searchValue }
589
- placeholder = "package-name"
590
- className = "bg-gray-500/10 rounded-md px-3 py-2 flex-1"
591
- onChange = { ( e ) => setSearchValue ( e . target . value ) }
592
- />
593
- </ form >
697
+ < PackageSearch />
594
698
< select
595
699
value = { interval }
596
700
onChange = { ( e ) =>
@@ -691,15 +795,6 @@ function RouteComponent() {
691
795
) }
692
796
</ button >
693
797
</ Tooltip >
694
- < Tooltip content = "Use as baseline for comparison" >
695
- < button
696
- onClick = { ( ) => handleBaselineChange ( packageName ) }
697
- className = "p-1 hover:text-blue-500"
698
- >
699
- { baseline === packageName ? < MdLock /> : < MdLockOpen /> }
700
- </ button >
701
- </ Tooltip >
702
-
703
798
< button
704
799
onClick = { ( ) => togglePackageVisibility ( packageName ) }
705
800
className = { `px-1 hover:text-blue-500 ${
@@ -708,6 +803,14 @@ function RouteComponent() {
708
803
>
709
804
{ packageName }
710
805
</ button >
806
+ < Tooltip content = "Use as baseline for comparison" >
807
+ < button
808
+ onClick = { ( ) => handleBaselineChange ( packageName ) }
809
+ className = "p-1 hover:text-blue-500"
810
+ >
811
+ { baseline === packageName ? < MdLock /> : < MdLockOpen /> }
812
+ </ button >
813
+ </ Tooltip >
711
814
< button
712
815
onClick = { ( ) => removePackageName ( packageName ) }
713
816
className = "p-1 text-gray-500 hover:text-red-500"
@@ -718,7 +821,7 @@ function RouteComponent() {
718
821
) ) }
719
822
</ div >
720
823
{ packageNames ?. length ? (
721
- < div className = "p-4 rounded-lg bg-white dark:bg-gray-900 " >
824
+ < div className = "" >
722
825
< div className = "space-y-4" >
723
826
< NpmStatsChart
724
827
stats = { validStats }
@@ -740,6 +843,9 @@ function RouteComponent() {
740
843
< th className = "px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider" >
741
844
Growth
742
845
</ th >
846
+ < th className = "px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider" >
847
+ % Growth
848
+ </ th >
743
849
</ tr >
744
850
</ thead >
745
851
< tbody className = "bg-white dark:bg-gray-900" >
@@ -756,7 +862,8 @@ function RouteComponent() {
756
862
const lastValue =
757
863
sortedDownloads [ sortedDownloads . length - 1 ]
758
864
?. downloads || 1
759
- const growth =
865
+ const growth = lastValue - firstValue
866
+ const growthPercentage =
760
867
( ( lastValue - firstValue ) / firstValue ) * 100
761
868
762
869
return {
@@ -766,6 +873,7 @@ function RouteComponent() {
766
873
0
767
874
) ,
768
875
growth,
876
+ growthPercentage,
769
877
}
770
878
} )
771
879
. filter ( Boolean )
@@ -788,7 +896,19 @@ function RouteComponent() {
788
896
} `}
789
897
>
790
898
{ stat ! . growth > 0 ? '+' : '' }
791
- { stat ! . growth . toFixed ( 1 ) } %
899
+ { formatNumber ( stat ! . growth ) }
900
+ </ td >
901
+ < td
902
+ className = { `px-6 py-4 whitespace-nowrap text-sm ${
903
+ stat ! . growthPercentage > 0
904
+ ? 'text-green-500'
905
+ : stat ! . growthPercentage < 0
906
+ ? 'text-red-500'
907
+ : 'text-gray-500'
908
+ } `}
909
+ >
910
+ { stat ! . growthPercentage > 0 ? '+' : '' }
911
+ { stat ! . growthPercentage . toFixed ( 1 ) } %
792
912
</ td >
793
913
</ tr >
794
914
) ) }
0 commit comments