+2
package.json
+2
package.json
···
14
14
"@atcute/client": "^2.0.7",
15
15
"@atcute/oauth-browser-client": "^1.0.9",
16
16
"@radix-ui/react-dialog": "^1.1.4",
17
+
"@radix-ui/react-popover": "^1.1.6",
17
18
"@radix-ui/react-progress": "^1.1.1",
18
19
"@radix-ui/react-separator": "^1.1.1",
19
20
"@radix-ui/react-slot": "^1.1.1",
···
26
27
"lexicons": "link:@atcute/bluesky/lexicons",
27
28
"lucide-react": "^0.469.0",
28
29
"preact": "^10.25.4",
30
+
"recharts": "^2.15.1",
29
31
"simple-icons": "^13.21.0",
30
32
"tailwind-merge": "^2.6.0",
31
33
"tailwindcss-animate": "^1.0.7"
+486
pnpm-lock.yaml
+486
pnpm-lock.yaml
···
23
23
'@radix-ui/react-dialog':
24
24
specifier: ^1.1.4
25
25
version: 1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
26
+
'@radix-ui/react-popover':
27
+
specifier: ^1.1.6
28
+
version: 1.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
26
29
'@radix-ui/react-progress':
27
30
specifier: ^1.1.1
28
31
version: 1.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
···
59
62
preact:
60
63
specifier: ^10.25.4
61
64
version: 10.25.4
65
+
recharts:
66
+
specifier: ^2.15.1
67
+
version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
62
68
simple-icons:
63
69
specifier: ^13.21.0
64
70
version: 13.21.0
···
206
212
engines: {node: '>=6.9.0'}
207
213
peerDependencies:
208
214
'@babel/core': ^7.0.0-0
215
+
216
+
'@babel/runtime@7.26.7':
217
+
resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
218
+
engines: {node: '>=6.9.0'}
209
219
210
220
'@babel/template@7.25.9':
211
221
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
···
605
615
'@types/react-dom':
606
616
optional: true
607
617
618
+
'@radix-ui/react-arrow@1.1.2':
619
+
resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
620
+
peerDependencies:
621
+
'@types/react': '*'
622
+
'@types/react-dom': '*'
623
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
624
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
625
+
peerDependenciesMeta:
626
+
'@types/react':
627
+
optional: true
628
+
'@types/react-dom':
629
+
optional: true
630
+
608
631
'@radix-ui/react-collection@1.1.1':
609
632
resolution: {integrity: sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==}
610
633
peerDependencies:
···
660
683
661
684
'@radix-ui/react-dismissable-layer@1.1.3':
662
685
resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==}
686
+
peerDependencies:
687
+
'@types/react': '*'
688
+
'@types/react-dom': '*'
689
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
690
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
691
+
peerDependenciesMeta:
692
+
'@types/react':
693
+
optional: true
694
+
'@types/react-dom':
695
+
optional: true
696
+
697
+
'@radix-ui/react-dismissable-layer@1.1.5':
698
+
resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==}
663
699
peerDependencies:
664
700
'@types/react': '*'
665
701
'@types/react-dom': '*'
···
693
729
'@types/react-dom':
694
730
optional: true
695
731
732
+
'@radix-ui/react-focus-scope@1.1.2':
733
+
resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==}
734
+
peerDependencies:
735
+
'@types/react': '*'
736
+
'@types/react-dom': '*'
737
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
738
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
739
+
peerDependenciesMeta:
740
+
'@types/react':
741
+
optional: true
742
+
'@types/react-dom':
743
+
optional: true
744
+
696
745
'@radix-ui/react-id@1.1.0':
697
746
resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==}
698
747
peerDependencies:
···
702
751
'@types/react':
703
752
optional: true
704
753
754
+
'@radix-ui/react-popover@1.1.6':
755
+
resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==}
756
+
peerDependencies:
757
+
'@types/react': '*'
758
+
'@types/react-dom': '*'
759
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
760
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
761
+
peerDependenciesMeta:
762
+
'@types/react':
763
+
optional: true
764
+
'@types/react-dom':
765
+
optional: true
766
+
705
767
'@radix-ui/react-popper@1.2.1':
706
768
resolution: {integrity: sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==}
707
769
peerDependencies:
···
715
777
'@types/react-dom':
716
778
optional: true
717
779
780
+
'@radix-ui/react-popper@1.2.2':
781
+
resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
782
+
peerDependencies:
783
+
'@types/react': '*'
784
+
'@types/react-dom': '*'
785
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
786
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
787
+
peerDependenciesMeta:
788
+
'@types/react':
789
+
optional: true
790
+
'@types/react-dom':
791
+
optional: true
792
+
718
793
'@radix-ui/react-portal@1.1.3':
719
794
resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==}
720
795
peerDependencies:
···
728
803
'@types/react-dom':
729
804
optional: true
730
805
806
+
'@radix-ui/react-portal@1.1.4':
807
+
resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==}
808
+
peerDependencies:
809
+
'@types/react': '*'
810
+
'@types/react-dom': '*'
811
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
812
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
813
+
peerDependenciesMeta:
814
+
'@types/react':
815
+
optional: true
816
+
'@types/react-dom':
817
+
optional: true
818
+
731
819
'@radix-ui/react-presence@1.1.2':
732
820
resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==}
733
821
peerDependencies:
···
743
831
744
832
'@radix-ui/react-primitive@2.0.1':
745
833
resolution: {integrity: sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==}
834
+
peerDependencies:
835
+
'@types/react': '*'
836
+
'@types/react-dom': '*'
837
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
838
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
839
+
peerDependenciesMeta:
840
+
'@types/react':
841
+
optional: true
842
+
'@types/react-dom':
843
+
optional: true
844
+
845
+
'@radix-ui/react-primitive@2.0.2':
846
+
resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==}
746
847
peerDependencies:
747
848
'@types/react': '*'
748
849
'@types/react-dom': '*'
···
795
896
796
897
'@radix-ui/react-slot@1.1.1':
797
898
resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==}
899
+
peerDependencies:
900
+
'@types/react': '*'
901
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
902
+
peerDependenciesMeta:
903
+
'@types/react':
904
+
optional: true
905
+
906
+
'@radix-ui/react-slot@1.1.2':
907
+
resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==}
798
908
peerDependencies:
799
909
'@types/react': '*'
800
910
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
···
1143
1253
'@types/babel__traverse@7.20.6':
1144
1254
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
1145
1255
1256
+
'@types/d3-array@3.2.1':
1257
+
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
1258
+
1259
+
'@types/d3-color@3.1.3':
1260
+
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
1261
+
1262
+
'@types/d3-ease@3.0.2':
1263
+
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
1264
+
1265
+
'@types/d3-interpolate@3.0.4':
1266
+
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
1267
+
1268
+
'@types/d3-path@3.1.1':
1269
+
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
1270
+
1271
+
'@types/d3-scale@4.0.9':
1272
+
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
1273
+
1274
+
'@types/d3-shape@3.1.7':
1275
+
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
1276
+
1277
+
'@types/d3-time@3.0.4':
1278
+
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
1279
+
1280
+
'@types/d3-timer@3.0.2':
1281
+
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
1282
+
1146
1283
'@types/diff@6.0.0':
1147
1284
resolution: {integrity: sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==}
1148
1285
···
1279
1416
csstype@3.1.3:
1280
1417
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
1281
1418
1419
+
d3-array@3.2.4:
1420
+
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
1421
+
engines: {node: '>=12'}
1422
+
1423
+
d3-color@3.1.0:
1424
+
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
1425
+
engines: {node: '>=12'}
1426
+
1427
+
d3-ease@3.0.1:
1428
+
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
1429
+
engines: {node: '>=12'}
1430
+
1431
+
d3-format@3.1.0:
1432
+
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
1433
+
engines: {node: '>=12'}
1434
+
1435
+
d3-interpolate@3.0.1:
1436
+
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
1437
+
engines: {node: '>=12'}
1438
+
1439
+
d3-path@3.1.0:
1440
+
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
1441
+
engines: {node: '>=12'}
1442
+
1443
+
d3-scale@4.0.2:
1444
+
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
1445
+
engines: {node: '>=12'}
1446
+
1447
+
d3-shape@3.2.0:
1448
+
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
1449
+
engines: {node: '>=12'}
1450
+
1451
+
d3-time-format@4.1.0:
1452
+
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
1453
+
engines: {node: '>=12'}
1454
+
1455
+
d3-time@3.1.0:
1456
+
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
1457
+
engines: {node: '>=12'}
1458
+
1459
+
d3-timer@3.0.1:
1460
+
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
1461
+
engines: {node: '>=12'}
1462
+
1282
1463
debug@4.4.0:
1283
1464
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
1284
1465
engines: {node: '>=6.0'}
···
1288
1469
supports-color:
1289
1470
optional: true
1290
1471
1472
+
decimal.js-light@2.5.1:
1473
+
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
1474
+
1291
1475
detect-node-es@1.1.0:
1292
1476
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
1293
1477
···
1300
1484
1301
1485
dlv@1.1.3:
1302
1486
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
1487
+
1488
+
dom-helpers@5.2.1:
1489
+
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
1303
1490
1304
1491
dom-serializer@2.0.0:
1305
1492
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
···
1347
1534
estree-walker@2.0.2:
1348
1535
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
1349
1536
1537
+
eventemitter3@4.0.7:
1538
+
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
1539
+
1540
+
fast-equals@5.2.2:
1541
+
resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==}
1542
+
engines: {node: '>=6.0.0'}
1543
+
1350
1544
fast-glob@3.3.3:
1351
1545
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
1352
1546
engines: {node: '>=8.6.0'}
···
1412
1606
he@1.2.0:
1413
1607
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
1414
1608
hasBin: true
1609
+
1610
+
internmap@2.0.3:
1611
+
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
1612
+
engines: {node: '>=12'}
1415
1613
1416
1614
is-binary-path@2.1.0:
1417
1615
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
···
1470
1668
lines-and-columns@1.2.4:
1471
1669
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
1472
1670
1671
+
lodash@4.17.21:
1672
+
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
1673
+
1674
+
loose-envify@1.4.0:
1675
+
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1676
+
hasBin: true
1677
+
1473
1678
lru-cache@10.4.3:
1474
1679
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
1475
1680
···
1615
1820
engines: {node: '>=14'}
1616
1821
hasBin: true
1617
1822
1823
+
prop-types@15.8.1:
1824
+
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1825
+
1618
1826
queue-microtask@1.2.3:
1619
1827
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
1620
1828
···
1623
1831
peerDependencies:
1624
1832
react: ^19.0.0
1625
1833
1834
+
react-is@16.13.1:
1835
+
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1836
+
1837
+
react-is@18.3.1:
1838
+
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
1839
+
1626
1840
react-remove-scroll-bar@2.3.8:
1627
1841
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
1628
1842
engines: {node: '>=10'}
···
1643
1857
'@types/react':
1644
1858
optional: true
1645
1859
1860
+
react-remove-scroll@2.6.3:
1861
+
resolution: {integrity: sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==}
1862
+
engines: {node: '>=10'}
1863
+
peerDependencies:
1864
+
'@types/react': '*'
1865
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
1866
+
peerDependenciesMeta:
1867
+
'@types/react':
1868
+
optional: true
1869
+
1870
+
react-smooth@4.0.4:
1871
+
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
1872
+
peerDependencies:
1873
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1874
+
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1875
+
1646
1876
react-style-singleton@2.2.3:
1647
1877
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
1648
1878
engines: {node: '>=10'}
···
1652
1882
peerDependenciesMeta:
1653
1883
'@types/react':
1654
1884
optional: true
1885
+
1886
+
react-transition-group@4.4.5:
1887
+
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
1888
+
peerDependencies:
1889
+
react: '>=16.6.0'
1890
+
react-dom: '>=16.6.0'
1655
1891
1656
1892
react@19.0.0:
1657
1893
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
···
1664
1900
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
1665
1901
engines: {node: '>=8.10.0'}
1666
1902
1903
+
recharts-scale@0.4.5:
1904
+
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
1905
+
1906
+
recharts@2.15.1:
1907
+
resolution: {integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==}
1908
+
engines: {node: '>=14'}
1909
+
peerDependencies:
1910
+
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1911
+
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1912
+
1913
+
regenerator-runtime@0.14.1:
1914
+
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
1915
+
1667
1916
resolve-pkg-maps@1.0.0:
1668
1917
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
1669
1918
···
1831
2080
util-deprecate@1.0.2:
1832
2081
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
1833
2082
2083
+
victory-vendor@36.9.2:
2084
+
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
2085
+
1834
2086
vite@6.0.7:
1835
2087
resolution: {integrity: sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==}
1836
2088
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
···
2030
2282
'@babel/types': 7.26.5
2031
2283
transitivePeerDependencies:
2032
2284
- supports-color
2285
+
2286
+
'@babel/runtime@7.26.7':
2287
+
dependencies:
2288
+
regenerator-runtime: 0.14.1
2033
2289
2034
2290
'@babel/template@7.25.9':
2035
2291
dependencies:
···
2307
2563
react: 19.0.0
2308
2564
react-dom: 19.0.0(react@19.0.0)
2309
2565
2566
+
'@radix-ui/react-arrow@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2567
+
dependencies:
2568
+
'@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2569
+
react: 19.0.0
2570
+
react-dom: 19.0.0(react@19.0.0)
2571
+
2310
2572
'@radix-ui/react-collection@1.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2311
2573
dependencies:
2312
2574
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
···
2357
2619
react: 19.0.0
2358
2620
react-dom: 19.0.0(react@19.0.0)
2359
2621
2622
+
'@radix-ui/react-dismissable-layer@1.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2623
+
dependencies:
2624
+
'@radix-ui/primitive': 1.1.1
2625
+
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
2626
+
'@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2627
+
'@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0)
2628
+
'@radix-ui/react-use-escape-keydown': 1.1.0(react@19.0.0)
2629
+
react: 19.0.0
2630
+
react-dom: 19.0.0(react@19.0.0)
2631
+
2360
2632
'@radix-ui/react-focus-guards@1.1.1(react@19.0.0)':
2361
2633
dependencies:
2362
2634
react: 19.0.0
···
2369
2641
react: 19.0.0
2370
2642
react-dom: 19.0.0(react@19.0.0)
2371
2643
2644
+
'@radix-ui/react-focus-scope@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2645
+
dependencies:
2646
+
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
2647
+
'@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2648
+
'@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0)
2649
+
react: 19.0.0
2650
+
react-dom: 19.0.0(react@19.0.0)
2651
+
2372
2652
'@radix-ui/react-id@1.1.0(react@19.0.0)':
2373
2653
dependencies:
2374
2654
'@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0)
2375
2655
react: 19.0.0
2376
2656
2657
+
'@radix-ui/react-popover@1.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2658
+
dependencies:
2659
+
'@radix-ui/primitive': 1.1.1
2660
+
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
2661
+
'@radix-ui/react-context': 1.1.1(react@19.0.0)
2662
+
'@radix-ui/react-dismissable-layer': 1.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2663
+
'@radix-ui/react-focus-guards': 1.1.1(react@19.0.0)
2664
+
'@radix-ui/react-focus-scope': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2665
+
'@radix-ui/react-id': 1.1.0(react@19.0.0)
2666
+
'@radix-ui/react-popper': 1.2.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2667
+
'@radix-ui/react-portal': 1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2668
+
'@radix-ui/react-presence': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2669
+
'@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2670
+
'@radix-ui/react-slot': 1.1.2(react@19.0.0)
2671
+
'@radix-ui/react-use-controllable-state': 1.1.0(react@19.0.0)
2672
+
aria-hidden: 1.2.4
2673
+
react: 19.0.0
2674
+
react-dom: 19.0.0(react@19.0.0)
2675
+
react-remove-scroll: 2.6.3(react@19.0.0)
2676
+
2377
2677
'@radix-ui/react-popper@1.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2378
2678
dependencies:
2379
2679
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
···
2389
2689
react: 19.0.0
2390
2690
react-dom: 19.0.0(react@19.0.0)
2391
2691
2692
+
'@radix-ui/react-popper@1.2.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2693
+
dependencies:
2694
+
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2695
+
'@radix-ui/react-arrow': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2696
+
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
2697
+
'@radix-ui/react-context': 1.1.1(react@19.0.0)
2698
+
'@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2699
+
'@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0)
2700
+
'@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0)
2701
+
'@radix-ui/react-use-rect': 1.1.0(react@19.0.0)
2702
+
'@radix-ui/react-use-size': 1.1.0(react@19.0.0)
2703
+
'@radix-ui/rect': 1.1.0
2704
+
react: 19.0.0
2705
+
react-dom: 19.0.0(react@19.0.0)
2706
+
2392
2707
'@radix-ui/react-portal@1.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2393
2708
dependencies:
2394
2709
'@radix-ui/react-primitive': 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
···
2396
2711
react: 19.0.0
2397
2712
react-dom: 19.0.0(react@19.0.0)
2398
2713
2714
+
'@radix-ui/react-portal@1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2715
+
dependencies:
2716
+
'@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
2717
+
'@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0)
2718
+
react: 19.0.0
2719
+
react-dom: 19.0.0(react@19.0.0)
2720
+
2399
2721
'@radix-ui/react-presence@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2400
2722
dependencies:
2401
2723
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
···
2406
2728
'@radix-ui/react-primitive@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2407
2729
dependencies:
2408
2730
'@radix-ui/react-slot': 1.1.1(react@19.0.0)
2731
+
react: 19.0.0
2732
+
react-dom: 19.0.0(react@19.0.0)
2733
+
2734
+
'@radix-ui/react-primitive@2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
2735
+
dependencies:
2736
+
'@radix-ui/react-slot': 1.1.2(react@19.0.0)
2409
2737
react: 19.0.0
2410
2738
react-dom: 19.0.0(react@19.0.0)
2411
2739
···
2437
2765
react-dom: 19.0.0(react@19.0.0)
2438
2766
2439
2767
'@radix-ui/react-slot@1.1.1(react@19.0.0)':
2768
+
dependencies:
2769
+
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
2770
+
react: 19.0.0
2771
+
2772
+
'@radix-ui/react-slot@1.1.2(react@19.0.0)':
2440
2773
dependencies:
2441
2774
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
2442
2775
react: 19.0.0
···
2787
3120
dependencies:
2788
3121
'@babel/types': 7.26.5
2789
3122
3123
+
'@types/d3-array@3.2.1': {}
3124
+
3125
+
'@types/d3-color@3.1.3': {}
3126
+
3127
+
'@types/d3-ease@3.0.2': {}
3128
+
3129
+
'@types/d3-interpolate@3.0.4':
3130
+
dependencies:
3131
+
'@types/d3-color': 3.1.3
3132
+
3133
+
'@types/d3-path@3.1.1': {}
3134
+
3135
+
'@types/d3-scale@4.0.9':
3136
+
dependencies:
3137
+
'@types/d3-time': 3.0.4
3138
+
3139
+
'@types/d3-shape@3.1.7':
3140
+
dependencies:
3141
+
'@types/d3-path': 3.1.1
3142
+
3143
+
'@types/d3-time@3.0.4': {}
3144
+
3145
+
'@types/d3-timer@3.0.2': {}
3146
+
2790
3147
'@types/diff@6.0.0': {}
2791
3148
2792
3149
'@types/estree@1.0.6': {}
···
2918
3275
2919
3276
csstype@3.1.3: {}
2920
3277
3278
+
d3-array@3.2.4:
3279
+
dependencies:
3280
+
internmap: 2.0.3
3281
+
3282
+
d3-color@3.1.0: {}
3283
+
3284
+
d3-ease@3.0.1: {}
3285
+
3286
+
d3-format@3.1.0: {}
3287
+
3288
+
d3-interpolate@3.0.1:
3289
+
dependencies:
3290
+
d3-color: 3.1.0
3291
+
3292
+
d3-path@3.1.0: {}
3293
+
3294
+
d3-scale@4.0.2:
3295
+
dependencies:
3296
+
d3-array: 3.2.4
3297
+
d3-format: 3.1.0
3298
+
d3-interpolate: 3.0.1
3299
+
d3-time: 3.1.0
3300
+
d3-time-format: 4.1.0
3301
+
3302
+
d3-shape@3.2.0:
3303
+
dependencies:
3304
+
d3-path: 3.1.0
3305
+
3306
+
d3-time-format@4.1.0:
3307
+
dependencies:
3308
+
d3-time: 3.1.0
3309
+
3310
+
d3-time@3.1.0:
3311
+
dependencies:
3312
+
d3-array: 3.2.4
3313
+
3314
+
d3-timer@3.0.1: {}
3315
+
2921
3316
debug@4.4.0:
2922
3317
dependencies:
2923
3318
ms: 2.1.3
2924
3319
3320
+
decimal.js-light@2.5.1: {}
3321
+
2925
3322
detect-node-es@1.1.0: {}
2926
3323
2927
3324
didyoumean@1.2.2: {}
···
2929
3326
diff@7.0.0: {}
2930
3327
2931
3328
dlv@1.1.3: {}
3329
+
3330
+
dom-helpers@5.2.1:
3331
+
dependencies:
3332
+
'@babel/runtime': 7.26.7
3333
+
csstype: 3.1.3
2932
3334
2933
3335
dom-serializer@2.0.0:
2934
3336
dependencies:
···
3017
3419
3018
3420
estree-walker@2.0.2: {}
3019
3421
3422
+
eventemitter3@4.0.7: {}
3423
+
3424
+
fast-equals@5.2.2: {}
3425
+
3020
3426
fast-glob@3.3.3:
3021
3427
dependencies:
3022
3428
'@nodelib/fs.stat': 2.0.5
···
3082
3488
3083
3489
he@1.2.0: {}
3084
3490
3491
+
internmap@2.0.3: {}
3492
+
3085
3493
is-binary-path@2.1.0:
3086
3494
dependencies:
3087
3495
binary-extensions: 2.3.0
···
3122
3530
3123
3531
lines-and-columns@1.2.4: {}
3124
3532
3533
+
lodash@4.17.21: {}
3534
+
3535
+
loose-envify@1.4.0:
3536
+
dependencies:
3537
+
js-tokens: 4.0.0
3538
+
3125
3539
lru-cache@10.4.3: {}
3126
3540
3127
3541
lru-cache@5.1.1:
···
3238
3652
3239
3653
prettier@3.4.2: {}
3240
3654
3655
+
prop-types@15.8.1:
3656
+
dependencies:
3657
+
loose-envify: 1.4.0
3658
+
object-assign: 4.1.1
3659
+
react-is: 16.13.1
3660
+
3241
3661
queue-microtask@1.2.3: {}
3242
3662
3243
3663
react-dom@19.0.0(react@19.0.0):
···
3245
3665
react: 19.0.0
3246
3666
scheduler: 0.25.0
3247
3667
3668
+
react-is@16.13.1: {}
3669
+
3670
+
react-is@18.3.1: {}
3671
+
3248
3672
react-remove-scroll-bar@2.3.8(react@19.0.0):
3249
3673
dependencies:
3250
3674
react: 19.0.0
···
3260
3684
use-callback-ref: 1.3.3(react@19.0.0)
3261
3685
use-sidecar: 1.1.3(react@19.0.0)
3262
3686
3687
+
react-remove-scroll@2.6.3(react@19.0.0):
3688
+
dependencies:
3689
+
react: 19.0.0
3690
+
react-remove-scroll-bar: 2.3.8(react@19.0.0)
3691
+
react-style-singleton: 2.2.3(react@19.0.0)
3692
+
tslib: 2.8.1
3693
+
use-callback-ref: 1.3.3(react@19.0.0)
3694
+
use-sidecar: 1.1.3(react@19.0.0)
3695
+
3696
+
react-smooth@4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
3697
+
dependencies:
3698
+
fast-equals: 5.2.2
3699
+
prop-types: 15.8.1
3700
+
react: 19.0.0
3701
+
react-dom: 19.0.0(react@19.0.0)
3702
+
react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
3703
+
3263
3704
react-style-singleton@2.2.3(react@19.0.0):
3264
3705
dependencies:
3265
3706
get-nonce: 1.0.1
3266
3707
react: 19.0.0
3267
3708
tslib: 2.8.1
3268
3709
3710
+
react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
3711
+
dependencies:
3712
+
'@babel/runtime': 7.26.7
3713
+
dom-helpers: 5.2.1
3714
+
loose-envify: 1.4.0
3715
+
prop-types: 15.8.1
3716
+
react: 19.0.0
3717
+
react-dom: 19.0.0(react@19.0.0)
3718
+
3269
3719
react@19.0.0: {}
3270
3720
3271
3721
read-cache@1.0.0:
···
3275
3725
readdirp@3.6.0:
3276
3726
dependencies:
3277
3727
picomatch: 2.3.1
3728
+
3729
+
recharts-scale@0.4.5:
3730
+
dependencies:
3731
+
decimal.js-light: 2.5.1
3732
+
3733
+
recharts@2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
3734
+
dependencies:
3735
+
clsx: 2.1.1
3736
+
eventemitter3: 4.0.7
3737
+
lodash: 4.17.21
3738
+
react: 19.0.0
3739
+
react-dom: 19.0.0(react@19.0.0)
3740
+
react-is: 18.3.1
3741
+
react-smooth: 4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
3742
+
recharts-scale: 0.4.5
3743
+
tiny-invariant: 1.3.3
3744
+
victory-vendor: 36.9.2
3745
+
3746
+
regenerator-runtime@0.14.1: {}
3278
3747
3279
3748
resolve-pkg-maps@1.0.0: {}
3280
3749
···
3458
3927
react: 19.0.0
3459
3928
3460
3929
util-deprecate@1.0.2: {}
3930
+
3931
+
victory-vendor@36.9.2:
3932
+
dependencies:
3933
+
'@types/d3-array': 3.2.1
3934
+
'@types/d3-ease': 3.0.2
3935
+
'@types/d3-interpolate': 3.0.4
3936
+
'@types/d3-scale': 4.0.9
3937
+
'@types/d3-shape': 3.1.7
3938
+
'@types/d3-time': 3.0.4
3939
+
'@types/d3-timer': 3.0.2
3940
+
d3-array: 3.2.4
3941
+
d3-ease: 3.0.1
3942
+
d3-interpolate: 3.0.1
3943
+
d3-scale: 4.0.2
3944
+
d3-shape: 3.2.0
3945
+
d3-time: 3.1.0
3946
+
d3-timer: 3.0.1
3461
3947
3462
3948
vite@6.0.7(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0):
3463
3949
dependencies:
+59
src/components/fontPicker.tsx
+59
src/components/fontPicker.tsx
···
1
+
// 2. Create Font Picker Component
2
+
import { useTheme, GOOGLE_FONTS } from "@/providers/themeProvider";
3
+
import { Button } from "./ui/button";
4
+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
5
+
import { Check, ChevronDown, Text } from "lucide-react";
6
+
import { useState } from "preact/hooks";
7
+
8
+
export function FontPicker() {
9
+
const { font, setFont, loadFonts, loadedFonts } = useTheme();
10
+
const [isOpen, setIsOpen] = useState(false);
11
+
12
+
const handleOpenChange = (open: boolean) => {
13
+
setIsOpen(open);
14
+
console.log("Setting open", open);
15
+
if (open) {
16
+
// Load all fonts when picker opens
17
+
loadFonts(GOOGLE_FONTS);
18
+
}
19
+
};
20
+
21
+
return (
22
+
<Popover open={isOpen} onOpenChange={handleOpenChange}>
23
+
<PopoverTrigger asChild>
24
+
<Button variant="outline" className="gap-2">
25
+
<Text className="h-4 w-4" />
26
+
{font.name}
27
+
<ChevronDown className="h-4 w-4 opacity-50" />
28
+
</Button>
29
+
</PopoverTrigger>
30
+
<PopoverContent className="w-[200px]">
31
+
<div className="grid gap-1 p-2">
32
+
{GOOGLE_FONTS.map((fontOption) => (
33
+
<button
34
+
key={fontOption.name}
35
+
onClick={() => {
36
+
setFont(fontOption);
37
+
setIsOpen(false);
38
+
}}
39
+
className="flex items-center justify-between rounded-sm p-2 text-sm hover:bg-accent"
40
+
style={{
41
+
fontFamily: `'${fontOption.name}', ${fontOption.category}`,
42
+
// Show loading state if font isn't loaded yet
43
+
opacity: loadedFonts.has(fontOption.url) ? 1 : 0.6,
44
+
}}
45
+
>
46
+
{fontOption.name}
47
+
{font.name === fontOption.name && (
48
+
<Check className="h-4 w-4 text-primary" />
49
+
)}
50
+
{!loadedFonts.has(fontOption.url) && (
51
+
<span className="h-4 w-4 animate-spin">⟳</span>
52
+
)}
53
+
</button>
54
+
))}
55
+
</div>
56
+
</PopoverContent>
57
+
</Popover>
58
+
);
59
+
}
+2
src/components/smartSearchBar.tsx
+2
src/components/smartSearchBar.tsx
+2
-2
src/components/themeSwitcher.tsx
+2
-2
src/components/themeSwitcher.tsx
···
5
5
import { Button } from "./ui/button";
6
6
7
7
export const ColorToggle = () => {
8
-
const { theme, toggleTheme } = useTheme();
8
+
const { theme, setTheme } = useTheme();
9
9
const [mounted, setMounted] = useState(false);
10
10
useEffect(() => {
11
11
setMounted(true);
···
20
20
className="flex items-center justify-center w-10 h-10 p-3 rounded-full bg-gray-200 dark:bg-gray-800 text-neutral-800 dark:text-neutral-300 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700"
21
21
aria-label="button"
22
22
onClick={() => {
23
-
toggleTheme();
23
+
setTheme(theme === "dark" ? "light" : "dark");
24
24
}}
25
25
>
26
26
<DarkLightIcon />
+1
-1
src/components/ui/kbdKey.tsx
+1
-1
src/components/ui/kbdKey.tsx
···
50
50
<div
51
51
ref={ref}
52
52
className={cn(
53
-
"inline-flex items-center justify-center rounded-md border bg-muted px-2 py-1 text-xs font-mono shadow",
53
+
"inline-flex items-center justify-center rounded-md border bg-muted px-2 py-1 text-xs font-mono shadow transition-colors duration-150",
54
54
keys ? "gap-x-[0.062rem]" : "gap-x-1",
55
55
allKeysPressed && "bg-green-300/50",
56
56
className,
+29
src/components/ui/popover.tsx
+29
src/components/ui/popover.tsx
···
1
+
import * as React from "react"
2
+
import * as PopoverPrimitive from "@radix-ui/react-popover"
3
+
4
+
import { cn } from "@/lib/utils"
5
+
6
+
const Popover = PopoverPrimitive.Root
7
+
8
+
const PopoverTrigger = PopoverPrimitive.Trigger
9
+
10
+
const PopoverContent = React.forwardRef<
11
+
React.ElementRef<typeof PopoverPrimitive.Content>,
12
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
13
+
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14
+
<PopoverPrimitive.Portal>
15
+
<PopoverPrimitive.Content
16
+
ref={ref}
17
+
align={align}
18
+
sideOffset={sideOffset}
19
+
className={cn(
20
+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
21
+
className
22
+
)}
23
+
{...props}
24
+
/>
25
+
</PopoverPrimitive.Portal>
26
+
))
27
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName
28
+
29
+
export { Popover, PopoverTrigger, PopoverContent }
+141
-119
src/index.css
+141
-119
src/index.css
···
3
3
@tailwind utilities;
4
4
5
5
@layer base {
6
-
:root {
7
-
--background: 0 0% 100%;
8
-
--foreground: 0 0% 3.9%;
9
-
--card: 0 0% 100%;
10
-
--card-foreground: 0 0% 3.9%;
11
-
--popover: 0 0% 100%;
12
-
--popover-foreground: 0 0% 3.9%;
13
-
--primary: 0 0% 9%;
14
-
--primary-foreground: 0 0% 98%;
15
-
--secondary: 0 0% 96.1%;
16
-
--secondary-foreground: 0 0% 9%;
17
-
--muted: 0 0% 96.1%;
18
-
--muted-foreground: 0 0% 45.1%;
19
-
--accent: 0 0% 96.1%;
20
-
--accent-foreground: 0 0% 9%;
21
-
--destructive: 0 84.2% 60.2%;
22
-
--destructive-foreground: 0 0% 98%;
23
-
--border: 0 0% 89.8%;
24
-
--input: 0 0% 89.8%;
25
-
--ring: 0 0% 3.9%;
26
-
--chart-1: 12 76% 61%;
27
-
--chart-2: 173 58% 39%;
28
-
--chart-3: 197 37% 24%;
29
-
--chart-4: 43 74% 66%;
30
-
--chart-5: 27 87% 67%;
31
-
--radius: 0.5rem;
32
-
--sidebar-background: 0 0% 98%;
33
-
--sidebar-foreground: 240 5.3% 26.1%;
34
-
--sidebar-primary: 240 5.9% 10%;
35
-
--sidebar-primary-foreground: 0 0% 98%;
36
-
--sidebar-accent: 240 4.8% 95.9%;
37
-
--sidebar-accent-foreground: 240 5.9% 10%;
38
-
--sidebar-border: 220 13% 91%;
39
-
--sidebar-ring: 217.2 91.2% 59.8%;
40
-
}
41
-
.dark {
42
-
--background: 0 0% 3.9%;
43
-
--foreground: 0 0% 98%;
44
-
--card: 0 0% 3.9%;
45
-
--card-foreground: 0 0% 98%;
46
-
--popover: 0 0% 3.9%;
47
-
--popover-foreground: 0 0% 98%;
48
-
--primary: 0 0% 98%;
49
-
--primary-foreground: 0 0% 9%;
50
-
--secondary: 0 0% 14.9%;
51
-
--secondary-foreground: 0 0% 98%;
52
-
--muted: 0 0% 14.9%;
53
-
--muted-foreground: 0 0% 63.9%;
54
-
--accent: 0 0% 14.9%;
55
-
--accent-foreground: 0 0% 98%;
56
-
--destructive: 0 62.8% 30.6%;
57
-
--destructive-foreground: 0 0% 98%;
58
-
--border: 0 0% 14.9%;
59
-
--input: 0 0% 14.9%;
60
-
--ring: 0 0% 83.1%;
61
-
--chart-1: 220 70% 50%;
62
-
--chart-2: 160 60% 45%;
63
-
--chart-3: 30 80% 55%;
64
-
--chart-4: 280 65% 60%;
65
-
--chart-5: 340 75% 55%;
66
-
--sidebar-background: 240 5.9% 10%;
67
-
--sidebar-foreground: 240 4.8% 95.9%;
68
-
--sidebar-primary: 224.3 76.3% 48%;
69
-
--sidebar-primary-foreground: 0 0% 100%;
70
-
--sidebar-accent: 240 3.7% 15.9%;
71
-
--sidebar-accent-foreground: 240 4.8% 95.9%;
72
-
--sidebar-border: 240 3.7% 15.9%;
73
-
--sidebar-ring: 217.2 91.2% 59.8%;
74
-
}
6
+
:root {
7
+
--background: 0 0% 100%;
8
+
--foreground: 0 0% 3.9%;
9
+
--card: 0 0% 100%;
10
+
--card-foreground: 0 0% 3.9%;
11
+
--popover: 0 0% 100%;
12
+
--popover-foreground: 0 0% 3.9%;
13
+
--primary: 0 0% 9%;
14
+
--primary-foreground: 0 0% 98%;
15
+
--secondary: 0 0% 96.1%;
16
+
--secondary-foreground: 0 0% 9%;
17
+
--muted: 0 0% 96.1%;
18
+
--muted-foreground: 0 0% 45.1%;
19
+
--accent: 0 0% 96.1%;
20
+
--accent-foreground: 0 0% 9%;
21
+
--destructive: 0 84.2% 60.2%;
22
+
--destructive-foreground: 0 0% 98%;
23
+
--border: 0 0% 89.8%;
24
+
--input: 0 0% 89.8%;
25
+
--ring: 0 0% 3.9%;
26
+
--chart-1: 12 76% 61%;
27
+
--chart-2: 173 58% 39%;
28
+
--chart-3: 197 37% 24%;
29
+
--chart-4: 43 74% 66%;
30
+
--chart-5: 27 87% 67%;
31
+
--radius: 0.5rem;
32
+
--sidebar-background: 0 0% 98%;
33
+
--sidebar-foreground: 240 5.3% 26.1%;
34
+
--sidebar-primary: 240 5.9% 10%;
35
+
--sidebar-primary-foreground: 0 0% 98%;
36
+
--sidebar-accent: 240 4.8% 95.9%;
37
+
--sidebar-accent-foreground: 240 5.9% 10%;
38
+
--sidebar-border: 220 13% 91%;
39
+
--sidebar-ring: 217.2 91.2% 59.8%;
40
+
}
41
+
.dark {
42
+
--background: 0 0% 3.9%;
43
+
--foreground: 0 0% 98%;
44
+
--card: 0 0% 3.9%;
45
+
--card-foreground: 0 0% 98%;
46
+
--popover: 0 0% 3.9%;
47
+
--popover-foreground: 0 0% 98%;
48
+
--primary: 0 0% 98%;
49
+
--primary-foreground: 0 0% 9%;
50
+
--secondary: 0 0% 14.9%;
51
+
--secondary-foreground: 0 0% 98%;
52
+
--muted: 0 0% 14.9%;
53
+
--muted-foreground: 0 0% 63.9%;
54
+
--accent: 0 0% 14.9%;
55
+
--accent-foreground: 0 0% 98%;
56
+
--destructive: 0 62.8% 30.6%;
57
+
--destructive-foreground: 0 0% 98%;
58
+
--border: 0 0% 14.9%;
59
+
--input: 0 0% 14.9%;
60
+
--ring: 0 0% 83.1%;
61
+
--chart-1: 220 70% 50%;
62
+
--chart-2: 160 60% 45%;
63
+
--chart-3: 30 80% 55%;
64
+
--chart-4: 280 65% 60%;
65
+
--chart-5: 340 75% 55%;
66
+
--sidebar-background: 240 5.9% 10%;
67
+
--sidebar-foreground: 240 4.8% 95.9%;
68
+
--sidebar-primary: 224.3 76.3% 48%;
69
+
--sidebar-primary-foreground: 0 0% 100%;
70
+
--sidebar-accent: 240 3.7% 15.9%;
71
+
--sidebar-accent-foreground: 240 4.8% 95.9%;
72
+
--sidebar-border: 240 3.7% 15.9%;
73
+
--sidebar-ring: 217.2 91.2% 59.8%;
74
+
}
75
75
}
76
76
@layer base {
77
-
* {
78
-
@apply border-border;
79
-
}
80
-
body {
81
-
@apply bg-background text-foreground;
82
-
}
77
+
* {
78
+
@apply border-border;
79
+
}
80
+
body {
81
+
@apply bg-background text-foreground;
82
+
}
83
83
}
84
84
85
85
.loader {
86
-
position: relative;
87
-
width: 54px;
88
-
height: 54px;
89
-
border-radius: 10px;
86
+
position: relative;
87
+
width: 54px;
88
+
height: 54px;
89
+
border-radius: 10px;
90
90
}
91
91
92
92
.loader div {
93
-
width: 8%;
94
-
height: 24%;
95
-
background: rgb(128, 128, 128);
96
-
position: absolute;
97
-
left: 50%;
98
-
top: 30%;
99
-
opacity: 0;
100
-
border-radius: 50px;
101
-
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
102
-
animation: fade458 1s linear infinite;
93
+
width: 8%;
94
+
height: 24%;
95
+
background: rgb(128, 128, 128);
96
+
position: absolute;
97
+
left: 50%;
98
+
top: 30%;
99
+
opacity: 0;
100
+
border-radius: 50px;
101
+
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
102
+
animation: fade458 1s linear infinite;
103
103
}
104
104
105
105
@keyframes fade458 {
106
-
from {
107
-
opacity: 1;
108
-
}
106
+
from {
107
+
opacity: 1;
108
+
}
109
109
110
-
to {
111
-
opacity: 0.25;
112
-
}
110
+
to {
111
+
opacity: 0.25;
112
+
}
113
113
}
114
114
115
115
.loader .bar1 {
116
-
transform: rotate(0deg) translate(0, -130%);
117
-
animation-delay: 0s;
116
+
transform: rotate(0deg) translate(0, -130%);
117
+
animation-delay: 0s;
118
118
}
119
119
120
120
.loader .bar2 {
121
-
transform: rotate(30deg) translate(0, -130%);
122
-
animation-delay: -1.1s;
121
+
transform: rotate(30deg) translate(0, -130%);
122
+
animation-delay: -1.1s;
123
123
}
124
124
125
125
.loader .bar3 {
126
-
transform: rotate(60deg) translate(0, -130%);
127
-
animation-delay: -1s;
126
+
transform: rotate(60deg) translate(0, -130%);
127
+
animation-delay: -1s;
128
128
}
129
129
130
130
.loader .bar4 {
131
-
transform: rotate(90deg) translate(0, -130%);
132
-
animation-delay: -0.9s;
131
+
transform: rotate(90deg) translate(0, -130%);
132
+
animation-delay: -0.9s;
133
133
}
134
134
135
135
.loader .bar5 {
136
-
transform: rotate(120deg) translate(0, -130%);
137
-
animation-delay: -0.8s;
136
+
transform: rotate(120deg) translate(0, -130%);
137
+
animation-delay: -0.8s;
138
138
}
139
139
140
140
.loader .bar6 {
141
-
transform: rotate(150deg) translate(0, -130%);
142
-
animation-delay: -0.7s;
141
+
transform: rotate(150deg) translate(0, -130%);
142
+
animation-delay: -0.7s;
143
143
}
144
144
145
145
.loader .bar7 {
146
-
transform: rotate(180deg) translate(0, -130%);
147
-
animation-delay: -0.6s;
146
+
transform: rotate(180deg) translate(0, -130%);
147
+
animation-delay: -0.6s;
148
148
}
149
149
150
150
.loader .bar8 {
151
-
transform: rotate(210deg) translate(0, -130%);
152
-
animation-delay: -0.5s;
151
+
transform: rotate(210deg) translate(0, -130%);
152
+
animation-delay: -0.5s;
153
153
}
154
154
155
155
.loader .bar9 {
156
-
transform: rotate(240deg) translate(0, -130%);
157
-
animation-delay: -0.4s;
156
+
transform: rotate(240deg) translate(0, -130%);
157
+
animation-delay: -0.4s;
158
158
}
159
159
160
160
.loader .bar10 {
161
-
transform: rotate(270deg) translate(0, -130%);
162
-
animation-delay: -0.3s;
161
+
transform: rotate(270deg) translate(0, -130%);
162
+
animation-delay: -0.3s;
163
163
}
164
164
165
165
.loader .bar11 {
166
-
transform: rotate(300deg) translate(0, -130%);
167
-
animation-delay: -0.2s;
166
+
transform: rotate(300deg) translate(0, -130%);
167
+
animation-delay: -0.2s;
168
168
}
169
169
170
170
.loader .bar12 {
171
-
transform: rotate(330deg) translate(0, -130%);
172
-
animation-delay: -0.1s;
171
+
transform: rotate(330deg) translate(0, -130%);
172
+
animation-delay: -0.1s;
173
+
}
174
+
175
+
@keyframes invertBlink {
176
+
0% {
177
+
filter: invert(100%);
178
+
}
179
+
30% {
180
+
filter: invert(100%);
181
+
}
182
+
50% {
183
+
filter: invert(0%);
184
+
}
185
+
80% {
186
+
filter: invert(0%);
187
+
}
188
+
100% {
189
+
filter: invert(100%);
190
+
}
191
+
}
192
+
193
+
.animate-blink {
194
+
animation: invertBlink 1s infinite;
173
195
}
+137
-31
src/providers/themeProvider.tsx
+137
-31
src/providers/themeProvider.tsx
···
1
-
import React, { createContext, useContext, useEffect, useState } from "react";
1
+
// 1. Create a Theme Context
2
+
import {
3
+
createContext,
4
+
useCallback,
5
+
useContext,
6
+
useEffect,
7
+
useState,
8
+
} from "react";
2
9
3
-
type Theme = "dark" | "light";
10
+
export type FontConfig = {
11
+
name: string;
12
+
category: "sans" | "serif" | "mono";
13
+
url: string;
14
+
};
4
15
5
-
type ThemeContextType = {
6
-
theme: Theme;
7
-
toggleTheme: () => void;
16
+
export const GOOGLE_FONTS: FontConfig[] = [
17
+
{
18
+
name: "Inter",
19
+
category: "sans",
20
+
url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap",
21
+
},
22
+
{
23
+
name: "Roboto",
24
+
category: "sans",
25
+
url: "https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap",
26
+
},
27
+
{
28
+
name: "IBM Plex Mono",
29
+
category: "mono",
30
+
url: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;700&display=swap",
31
+
},
32
+
{
33
+
name: "Space Mono",
34
+
category: "mono",
35
+
url: "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap",
36
+
},
37
+
{
38
+
name: "DM Mono",
39
+
category: "mono",
40
+
url: "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;700&display=swap",
41
+
},
42
+
{
43
+
name: "Fira Mono",
44
+
category: "mono",
45
+
url: "https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;700&display=swap",
46
+
},
47
+
{
48
+
name: "Overpass Mono",
49
+
category: "mono",
50
+
url: "https://fonts.googleapis.com/css2?family=Overpass+Mono:wght@400;700&display=swap",
51
+
},
52
+
{
53
+
name: "Atkinson Hyperlegible Mono",
54
+
category: "mono",
55
+
url: "https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;700&display=swap",
56
+
},
57
+
{
58
+
name: "Geist Mono",
59
+
category: "mono",
60
+
url: "https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;700&display=swap",
61
+
},
62
+
];
63
+
64
+
export type ThemeState = {
65
+
theme: "light" | "dark";
66
+
font: FontConfig;
67
+
loadedFonts: Set<string>;
68
+
loadFonts: (fonts: FontConfig[]) => void;
69
+
setTheme: (theme: "light" | "dark") => void;
70
+
setFont: (font: FontConfig) => void;
8
71
};
9
72
10
-
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
73
+
const ThemeContext = createContext<ThemeState>({} as ThemeState);
11
74
12
75
export function ThemeProvider({ children }: { children: React.ReactNode }) {
13
-
// Initialize with undefined to prevent hydration mismatch
14
-
const [theme, setTheme] = useState<Theme | undefined>(undefined);
76
+
const [theme, setTheme] = useState<"light" | "dark">("light");
77
+
const [font, setFont] = useState<FontConfig>(GOOGLE_FONTS[0]);
78
+
const [loadedFonts, setLoadedFonts] = useState<Set<string>>(new Set());
15
79
80
+
const loadFonts = useCallback(
81
+
(fonts: FontConfig[]) => {
82
+
console.log("Loading multiple fonts");
83
+
fonts.forEach((fontConfig) => {
84
+
if (!loadedFonts.has(fontConfig.url)) {
85
+
const link = document.createElement("link");
86
+
link.rel = "stylesheet";
87
+
link.href = fontConfig.url;
88
+
link.crossOrigin = "anonymous";
89
+
link.dataset.font = fontConfig.name;
90
+
document.head.appendChild(link);
91
+
92
+
setLoadedFonts((prev) => new Set(prev).add(fontConfig.url));
93
+
}
94
+
});
95
+
},
96
+
[loadedFonts],
97
+
);
98
+
99
+
// Load saved theme/font from localStorage
16
100
useEffect(() => {
17
-
// Get initial theme on mount
18
-
const root = window.document.documentElement;
19
-
const initialColorValue = root.className as Theme;
20
-
setTheme(initialColorValue);
101
+
const savedTheme =
102
+
(localStorage.getItem("theme") as "light" | "dark") || "light";
103
+
const savedFont =
104
+
JSON.parse(localStorage.getItem("font") || "null") || GOOGLE_FONTS[0];
105
+
setTheme(savedTheme);
106
+
setFont(savedFont);
21
107
}, []);
22
108
109
+
// Manage font loading
23
110
useEffect(() => {
24
-
if (theme) {
25
-
// Update class and localStorage when theme changes
26
-
const root = window.document.documentElement;
27
-
root.className = theme;
28
-
localStorage.setItem("theme", theme);
111
+
// Remove existing font links
112
+
const existingLinks = document.querySelectorAll("link[data-font]");
113
+
existingLinks.forEach((link) => link.remove());
114
+
115
+
// Create new font link
116
+
const link = document.createElement("link");
117
+
link.rel = "stylesheet";
118
+
link.href = font.url;
119
+
link.dataset.font = font.name;
120
+
document.head.appendChild(link);
121
+
122
+
setLoadedFonts(new Set(font.url));
123
+
124
+
// Update CSS variables
125
+
document.documentElement.style.setProperty(
126
+
`--font-${font.category}`,
127
+
`'${font.name}', ${font.category === "sans" ? "sans-serif" : font.category}`,
128
+
);
129
+
// assume we want everything to be this font
130
+
document.documentElement.style.setProperty(
131
+
`--font-sans`,
132
+
`'${font.name}', sans-serif`,
133
+
);
134
+
135
+
if (font.category !== "mono") {
136
+
document.documentElement.style.setProperty(`--font-mono`, `mono`);
29
137
}
30
-
}, [theme]);
31
138
32
-
const toggleTheme = () => {
33
-
setTheme((prev) => (prev === "light" ? "dark" : "light"));
34
-
};
139
+
// Save to localStorage
140
+
localStorage.setItem("font", JSON.stringify(font));
141
+
localStorage.setItem("theme", theme);
142
+
}, [font, theme]);
35
143
36
-
// Only render children once theme is set
37
-
if (theme === undefined) return null;
144
+
// Update theme class
145
+
useEffect(() => {
146
+
document.documentElement.setAttribute("data-theme", theme);
147
+
}, [theme]);
38
148
39
149
return (
40
-
<ThemeContext.Provider value={{ theme, toggleTheme }}>
150
+
<ThemeContext.Provider
151
+
value={{ theme, font, setTheme, setFont, loadFonts, loadedFonts }}
152
+
>
41
153
{children}
42
154
</ThemeContext.Provider>
43
155
);
44
156
}
45
157
46
-
export function useTheme() {
47
-
const context = useContext(ThemeContext);
48
-
if (context === undefined) {
49
-
throw new Error("useTheme must be used within a ThemeProvider");
50
-
}
51
-
return context;
52
-
}
158
+
export const useTheme = () => useContext(ThemeContext);
+68
-10
src/routeTree.gen.ts
+68
-10
src/routeTree.gen.ts
···
14
14
15
15
import { Route as rootRoute } from './routes/__root'
16
16
import { Route as FirehoseIndexImport } from './routes/firehose/index'
17
-
import { Route as PdsUrlIndexImport } from './routes/pds/$url.index'
18
17
import { Route as AtHandleIndexImport } from './routes/at:/$handle.index'
19
18
20
19
// Create Virtual Routes
···
22
21
const CounterLazyImport = createFileRoute('/counter')()
23
22
const AboutLazyImport = createFileRoute('/about')()
24
23
const IndexLazyImport = createFileRoute('/')()
24
+
const RnfgrerttIndexLazyImport = createFileRoute('/rnfgrertt/')()
25
+
const RnfgrerttTypingLazyImport = createFileRoute('/rnfgrertt/typing')()
26
+
const PdsUrlIndexLazyImport = createFileRoute('/pds/$url/')()
25
27
const AtHandleCollectionIndexLazyImport = createFileRoute(
26
28
'/at:/$handle/$collection/',
27
29
)()
···
49
51
getParentRoute: () => rootRoute,
50
52
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
51
53
54
+
const RnfgrerttIndexLazyRoute = RnfgrerttIndexLazyImport.update({
55
+
id: '/rnfgrertt/',
56
+
path: '/rnfgrertt/',
57
+
getParentRoute: () => rootRoute,
58
+
} as any).lazy(() =>
59
+
import('./routes/rnfgrertt/index.lazy').then((d) => d.Route),
60
+
)
61
+
52
62
const FirehoseIndexRoute = FirehoseIndexImport.update({
53
63
id: '/firehose/',
54
64
path: '/firehose/',
55
65
getParentRoute: () => rootRoute,
56
66
} as any)
57
67
58
-
const PdsUrlIndexRoute = PdsUrlIndexImport.update({
68
+
const RnfgrerttTypingLazyRoute = RnfgrerttTypingLazyImport.update({
69
+
id: '/rnfgrertt/typing',
70
+
path: '/rnfgrertt/typing',
71
+
getParentRoute: () => rootRoute,
72
+
} as any).lazy(() =>
73
+
import('./routes/rnfgrertt/typing.lazy').then((d) => d.Route),
74
+
)
75
+
76
+
const PdsUrlIndexLazyRoute = PdsUrlIndexLazyImport.update({
59
77
id: '/pds/$url/',
60
78
path: '/pds/$url/',
61
79
getParentRoute: () => rootRoute,
62
-
} as any)
80
+
} as any).lazy(() =>
81
+
import('./routes/pds/$url.index.lazy').then((d) => d.Route),
82
+
)
63
83
64
84
const AtHandleIndexRoute = AtHandleIndexImport.update({
65
85
id: '/at:/$handle/',
···
111
131
preLoaderRoute: typeof CounterLazyImport
112
132
parentRoute: typeof rootRoute
113
133
}
134
+
'/rnfgrertt/typing': {
135
+
id: '/rnfgrertt/typing'
136
+
path: '/rnfgrertt/typing'
137
+
fullPath: '/rnfgrertt/typing'
138
+
preLoaderRoute: typeof RnfgrerttTypingLazyImport
139
+
parentRoute: typeof rootRoute
140
+
}
114
141
'/firehose/': {
115
142
id: '/firehose/'
116
143
path: '/firehose'
···
118
145
preLoaderRoute: typeof FirehoseIndexImport
119
146
parentRoute: typeof rootRoute
120
147
}
148
+
'/rnfgrertt/': {
149
+
id: '/rnfgrertt/'
150
+
path: '/rnfgrertt'
151
+
fullPath: '/rnfgrertt'
152
+
preLoaderRoute: typeof RnfgrerttIndexLazyImport
153
+
parentRoute: typeof rootRoute
154
+
}
121
155
'/at:/$handle/': {
122
156
id: '/at:/$handle/'
123
157
path: '/at:/$handle'
···
129
163
id: '/pds/$url/'
130
164
path: '/pds/$url'
131
165
fullPath: '/pds/$url'
132
-
preLoaderRoute: typeof PdsUrlIndexImport
166
+
preLoaderRoute: typeof PdsUrlIndexLazyImport
133
167
parentRoute: typeof rootRoute
134
168
}
135
169
'/at:/$handle/$collection/$rkey': {
···
155
189
'/': typeof IndexLazyRoute
156
190
'/about': typeof AboutLazyRoute
157
191
'/counter': typeof CounterLazyRoute
192
+
'/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute
158
193
'/firehose': typeof FirehoseIndexRoute
194
+
'/rnfgrertt': typeof RnfgrerttIndexLazyRoute
159
195
'/at:/$handle': typeof AtHandleIndexRoute
160
-
'/pds/$url': typeof PdsUrlIndexRoute
196
+
'/pds/$url': typeof PdsUrlIndexLazyRoute
161
197
'/at:/$handle/$collection/$rkey': typeof AtHandleCollectionRkeyLazyRoute
162
198
'/at:/$handle/$collection': typeof AtHandleCollectionIndexLazyRoute
163
199
}
···
166
202
'/': typeof IndexLazyRoute
167
203
'/about': typeof AboutLazyRoute
168
204
'/counter': typeof CounterLazyRoute
205
+
'/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute
169
206
'/firehose': typeof FirehoseIndexRoute
207
+
'/rnfgrertt': typeof RnfgrerttIndexLazyRoute
170
208
'/at:/$handle': typeof AtHandleIndexRoute
171
-
'/pds/$url': typeof PdsUrlIndexRoute
209
+
'/pds/$url': typeof PdsUrlIndexLazyRoute
172
210
'/at:/$handle/$collection/$rkey': typeof AtHandleCollectionRkeyLazyRoute
173
211
'/at:/$handle/$collection': typeof AtHandleCollectionIndexLazyRoute
174
212
}
···
178
216
'/': typeof IndexLazyRoute
179
217
'/about': typeof AboutLazyRoute
180
218
'/counter': typeof CounterLazyRoute
219
+
'/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute
181
220
'/firehose/': typeof FirehoseIndexRoute
221
+
'/rnfgrertt/': typeof RnfgrerttIndexLazyRoute
182
222
'/at:/$handle/': typeof AtHandleIndexRoute
183
-
'/pds/$url/': typeof PdsUrlIndexRoute
223
+
'/pds/$url/': typeof PdsUrlIndexLazyRoute
184
224
'/at:/$handle/$collection/$rkey': typeof AtHandleCollectionRkeyLazyRoute
185
225
'/at:/$handle/$collection/': typeof AtHandleCollectionIndexLazyRoute
186
226
}
···
191
231
| '/'
192
232
| '/about'
193
233
| '/counter'
234
+
| '/rnfgrertt/typing'
194
235
| '/firehose'
236
+
| '/rnfgrertt'
195
237
| '/at:/$handle'
196
238
| '/pds/$url'
197
239
| '/at:/$handle/$collection/$rkey'
···
201
243
| '/'
202
244
| '/about'
203
245
| '/counter'
246
+
| '/rnfgrertt/typing'
204
247
| '/firehose'
248
+
| '/rnfgrertt'
205
249
| '/at:/$handle'
206
250
| '/pds/$url'
207
251
| '/at:/$handle/$collection/$rkey'
···
211
255
| '/'
212
256
| '/about'
213
257
| '/counter'
258
+
| '/rnfgrertt/typing'
214
259
| '/firehose/'
260
+
| '/rnfgrertt/'
215
261
| '/at:/$handle/'
216
262
| '/pds/$url/'
217
263
| '/at:/$handle/$collection/$rkey'
···
223
269
IndexLazyRoute: typeof IndexLazyRoute
224
270
AboutLazyRoute: typeof AboutLazyRoute
225
271
CounterLazyRoute: typeof CounterLazyRoute
272
+
RnfgrerttTypingLazyRoute: typeof RnfgrerttTypingLazyRoute
226
273
FirehoseIndexRoute: typeof FirehoseIndexRoute
274
+
RnfgrerttIndexLazyRoute: typeof RnfgrerttIndexLazyRoute
227
275
AtHandleIndexRoute: typeof AtHandleIndexRoute
228
-
PdsUrlIndexRoute: typeof PdsUrlIndexRoute
276
+
PdsUrlIndexLazyRoute: typeof PdsUrlIndexLazyRoute
229
277
AtHandleCollectionRkeyLazyRoute: typeof AtHandleCollectionRkeyLazyRoute
230
278
AtHandleCollectionIndexLazyRoute: typeof AtHandleCollectionIndexLazyRoute
231
279
}
···
234
282
IndexLazyRoute: IndexLazyRoute,
235
283
AboutLazyRoute: AboutLazyRoute,
236
284
CounterLazyRoute: CounterLazyRoute,
285
+
RnfgrerttTypingLazyRoute: RnfgrerttTypingLazyRoute,
237
286
FirehoseIndexRoute: FirehoseIndexRoute,
287
+
RnfgrerttIndexLazyRoute: RnfgrerttIndexLazyRoute,
238
288
AtHandleIndexRoute: AtHandleIndexRoute,
239
-
PdsUrlIndexRoute: PdsUrlIndexRoute,
289
+
PdsUrlIndexLazyRoute: PdsUrlIndexLazyRoute,
240
290
AtHandleCollectionRkeyLazyRoute: AtHandleCollectionRkeyLazyRoute,
241
291
AtHandleCollectionIndexLazyRoute: AtHandleCollectionIndexLazyRoute,
242
292
}
···
254
304
"/",
255
305
"/about",
256
306
"/counter",
307
+
"/rnfgrertt/typing",
257
308
"/firehose/",
309
+
"/rnfgrertt/",
258
310
"/at:/$handle/",
259
311
"/pds/$url/",
260
312
"/at:/$handle/$collection/$rkey",
···
270
322
"/counter": {
271
323
"filePath": "counter.lazy.tsx"
272
324
},
325
+
"/rnfgrertt/typing": {
326
+
"filePath": "rnfgrertt/typing.lazy.tsx"
327
+
},
273
328
"/firehose/": {
274
329
"filePath": "firehose/index.tsx"
275
330
},
331
+
"/rnfgrertt/": {
332
+
"filePath": "rnfgrertt/index.lazy.tsx"
333
+
},
276
334
"/at:/$handle/": {
277
335
"filePath": "at:/$handle.index.tsx"
278
336
},
279
337
"/pds/$url/": {
280
-
"filePath": "pds/$url.index.tsx"
338
+
"filePath": "pds/$url.index.lazy.tsx"
281
339
},
282
340
"/at:/$handle/$collection/$rkey": {
283
341
"filePath": "at:/$handle/$collection.$rkey.lazy.tsx"
+167
src/routes/pds/$url.index.lazy.tsx
+167
src/routes/pds/$url.index.lazy.tsx
···
1
+
import ShowError from '@/components/error'
2
+
import { Loader } from '@/components/ui/loader'
3
+
import { useDocumentTitle } from '@/hooks/useDocumentTitle'
4
+
import { QtClient, useXrpc } from '@/providers/qtprovider'
5
+
import { ComAtprotoSyncListRepos } from '@atcute/client/lexicons'
6
+
import { createLazyFileRoute, Link } from '@tanstack/react-router'
7
+
import { ShieldX } from 'lucide-react'
8
+
import { useState, useEffect, useRef } from 'preact/compat'
9
+
10
+
interface PdsData {
11
+
records?: ComAtprotoSyncListRepos.Output['repos']
12
+
cursor?: string
13
+
health?: PdsHealth
14
+
isLoading: boolean
15
+
error: Error | null
16
+
fetchMore: (cursor: string) => Promise<void>
17
+
}
18
+
19
+
interface PdsHealth {
20
+
version: string
21
+
}
22
+
23
+
function useRepoData(baseUrl: string): PdsData {
24
+
const xrpc = useXrpc()
25
+
const [state, setState] = useState<PdsData>({
26
+
isLoading: true,
27
+
error: null,
28
+
fetchMore: async () => {},
29
+
})
30
+
31
+
useDocumentTitle(baseUrl ? `${baseUrl} | atp.tools` : 'atp.tools')
32
+
const abortController = new AbortController()
33
+
34
+
async function fetchRepoData(cursor?: string) {
35
+
try {
36
+
setState((prev) => ({ ...prev, isLoading: true }))
37
+
38
+
// we dont use the main authenticated client here
39
+
const rpc = new QtClient(new URL('https://' + baseUrl))
40
+
// get the PDS
41
+
const response = await rpc
42
+
.getXrpcClient()
43
+
.get('com.atproto.sync.listRepos', {
44
+
params: { limit: 1000, cursor },
45
+
signal: abortController.signal,
46
+
})
47
+
48
+
const health = await rpc
49
+
.getXrpcClient()
50
+
.request({ nsid: '_health', type: 'get' })
51
+
52
+
setState((prev) => ({
53
+
...prev,
54
+
records: cursor
55
+
? [...(prev.records || []), ...response.data.repos]
56
+
: response.data.repos,
57
+
cursor: response.data.cursor,
58
+
health: health.data,
59
+
isLoading: false,
60
+
error: null,
61
+
}))
62
+
63
+
// todo: actual errors
64
+
} catch (err: any) {
65
+
if (err.name === 'AbortError') return
66
+
67
+
setState((prev) => ({
68
+
...prev,
69
+
isLoading: false,
70
+
error: err instanceof Error ? err : new Error('An error occurred'),
71
+
}))
72
+
}
73
+
}
74
+
75
+
useEffect(() => {
76
+
fetchRepoData()
77
+
78
+
return () => {
79
+
abortController.abort()
80
+
}
81
+
}, [baseUrl, xrpc])
82
+
83
+
const fetchMore = async (cursor: string) => {
84
+
if (cursor && !state.isLoading) {
85
+
await fetchRepoData(cursor)
86
+
}
87
+
}
88
+
89
+
return { ...state, fetchMore }
90
+
}
91
+
92
+
export const Route = createLazyFileRoute('/pds/$url/')({
93
+
component: RouteComponent,
94
+
})
95
+
96
+
function RouteComponent() {
97
+
const { url } = Route.useParams()
98
+
const { cursor, records, fetchMore, isLoading, error } = useRepoData(url)
99
+
100
+
useDocumentTitle(records ? `${url} (PDS) | atp.tools` : 'atp.tools')
101
+
102
+
const loaderRef = useRef<HTMLDivElement>(null)
103
+
useEffect(() => {
104
+
if (!loaderRef.current) return
105
+
106
+
const observer = new IntersectionObserver(
107
+
(entries) => {
108
+
const target = entries[0]
109
+
if (target.isIntersecting && !isLoading && cursor) {
110
+
fetchMore(cursor)
111
+
}
112
+
},
113
+
{ threshold: 0.1, rootMargin: '50px' },
114
+
)
115
+
116
+
observer.observe(loaderRef.current)
117
+
return () => observer.disconnect()
118
+
}, [cursor, isLoading, fetchMore])
119
+
120
+
if (error) {
121
+
return <ShowError error={error} />
122
+
}
123
+
124
+
if ((isLoading && !cursor) || !records) {
125
+
return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />
126
+
}
127
+
128
+
return (
129
+
<div className="flex flex-row justify-center w-full">
130
+
<div className="max-w-2xl w-screen p-4 md:mt-16 space-y-2">
131
+
<div>
132
+
PDS: {url.includes('bsky.network') && '🍄'} {url}
133
+
</div>
134
+
135
+
<div>
136
+
<h2 className="text-xl font-bold">Repositories (accounts)</h2>
137
+
<ul>
138
+
{records?.map((c) => (
139
+
<li key={c.did} className="text-blue-500">
140
+
<Link
141
+
to="/at:/$handle"
142
+
params={{
143
+
handle: c.did,
144
+
}}
145
+
>
146
+
{c.did}{' '}
147
+
{!c.active && <ShieldX className="inline text-red-500" />}
148
+
</Link>
149
+
</li>
150
+
))}
151
+
</ul>
152
+
<div
153
+
ref={loaderRef}
154
+
className="flex flex-row justify-center h-10 -pt-16"
155
+
>
156
+
{isLoading && <Loader className="max-h-16 h-screen" />}
157
+
{!isLoading && !cursor && (
158
+
<div className="text-center text-sm text-muted-foreground mx-10 mt-2">
159
+
that's all, folks!
160
+
</div>
161
+
)}
162
+
</div>
163
+
</div>
164
+
</div>
165
+
</div>
166
+
)
167
+
}
-167
src/routes/pds/$url.index.tsx
-167
src/routes/pds/$url.index.tsx
···
1
-
import ShowError from "@/components/error";
2
-
import { Loader } from "@/components/ui/loader";
3
-
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
4
-
import { QtClient, useXrpc } from "@/providers/qtprovider";
5
-
import { ComAtprotoSyncListRepos } from "@atcute/client/lexicons";
6
-
import { createFileRoute, Link } from "@tanstack/react-router";
7
-
import { ShieldX } from "lucide-react";
8
-
import { useState, useEffect, useRef } from "preact/compat";
9
-
10
-
interface PdsData {
11
-
records?: ComAtprotoSyncListRepos.Output["repos"];
12
-
cursor?: string;
13
-
health?: PdsHealth;
14
-
isLoading: boolean;
15
-
error: Error | null;
16
-
fetchMore: (cursor: string) => Promise<void>;
17
-
}
18
-
19
-
interface PdsHealth {
20
-
version: string;
21
-
}
22
-
23
-
function useRepoData(baseUrl: string): PdsData {
24
-
const xrpc = useXrpc();
25
-
const [state, setState] = useState<PdsData>({
26
-
isLoading: true,
27
-
error: null,
28
-
fetchMore: async () => {},
29
-
});
30
-
31
-
useDocumentTitle(baseUrl ? `${baseUrl} | atp.tools` : "atp.tools");
32
-
const abortController = new AbortController();
33
-
34
-
async function fetchRepoData(cursor?: string) {
35
-
try {
36
-
setState((prev) => ({ ...prev, isLoading: true }));
37
-
38
-
// we dont use the main authenticated client here
39
-
const rpc = new QtClient(new URL("https://" + baseUrl));
40
-
// get the PDS
41
-
const response = await rpc
42
-
.getXrpcClient()
43
-
.get("com.atproto.sync.listRepos", {
44
-
params: { limit: 1000, cursor },
45
-
signal: abortController.signal,
46
-
});
47
-
48
-
const health = await rpc
49
-
.getXrpcClient()
50
-
.request({ nsid: "_health", type: "get" });
51
-
52
-
setState((prev) => ({
53
-
...prev,
54
-
records: cursor
55
-
? [...(prev.records || []), ...response.data.repos]
56
-
: response.data.repos,
57
-
cursor: response.data.cursor,
58
-
health: health.data,
59
-
isLoading: false,
60
-
error: null,
61
-
}));
62
-
63
-
// todo: actual errors
64
-
} catch (err: any) {
65
-
if (err.name === "AbortError") return;
66
-
67
-
setState((prev) => ({
68
-
...prev,
69
-
isLoading: false,
70
-
error: err instanceof Error ? err : new Error("An error occurred"),
71
-
}));
72
-
}
73
-
}
74
-
75
-
useEffect(() => {
76
-
fetchRepoData();
77
-
78
-
return () => {
79
-
abortController.abort();
80
-
};
81
-
}, [baseUrl, xrpc]);
82
-
83
-
const fetchMore = async (cursor: string) => {
84
-
if (cursor && !state.isLoading) {
85
-
await fetchRepoData(cursor);
86
-
}
87
-
};
88
-
89
-
return { ...state, fetchMore };
90
-
}
91
-
92
-
export const Route = createFileRoute("/pds/$url/")({
93
-
component: RouteComponent,
94
-
});
95
-
96
-
function RouteComponent() {
97
-
const { url } = Route.useParams();
98
-
const { cursor, records, fetchMore, isLoading, error } = useRepoData(url);
99
-
100
-
useDocumentTitle(records ? `${url} (PDS) | atp.tools` : "atp.tools");
101
-
102
-
const loaderRef = useRef<HTMLDivElement>(null);
103
-
useEffect(() => {
104
-
if (!loaderRef.current) return;
105
-
106
-
const observer = new IntersectionObserver(
107
-
(entries) => {
108
-
const target = entries[0];
109
-
if (target.isIntersecting && !isLoading && cursor) {
110
-
fetchMore(cursor);
111
-
}
112
-
},
113
-
{ threshold: 0.1, rootMargin: "50px" },
114
-
);
115
-
116
-
observer.observe(loaderRef.current);
117
-
return () => observer.disconnect();
118
-
}, [cursor, isLoading, fetchMore]);
119
-
120
-
if (error) {
121
-
return <ShowError error={error} />;
122
-
}
123
-
124
-
if ((isLoading && !cursor) || !records) {
125
-
return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />;
126
-
}
127
-
128
-
return (
129
-
<div className="flex flex-row justify-center w-full">
130
-
<div className="max-w-2xl w-screen p-4 md:mt-16 space-y-2">
131
-
<div>
132
-
PDS: {url.includes("bsky.network") && "🍄"} {url}
133
-
</div>
134
-
135
-
<div>
136
-
<h2 className="text-xl font-bold">Repositories (accounts)</h2>
137
-
<ul>
138
-
{records?.map((c) => (
139
-
<li key={c.did} className="text-blue-500">
140
-
<Link
141
-
to="/at:/$handle"
142
-
params={{
143
-
handle: c.did,
144
-
}}
145
-
>
146
-
{c.did}{" "}
147
-
{!c.active && <ShieldX className="inline text-red-500" />}
148
-
</Link>
149
-
</li>
150
-
))}
151
-
</ul>
152
-
<div
153
-
ref={loaderRef}
154
-
className="flex flex-row justify-center h-10 -pt-16"
155
-
>
156
-
{isLoading && <Loader className="max-h-16 h-screen" />}
157
-
{!isLoading && !cursor && (
158
-
<div className="text-center text-sm text-muted-foreground mx-10 mt-2">
159
-
that's all, folks!
160
-
</div>
161
-
)}
162
-
</div>
163
-
</div>
164
-
</div>
165
-
</div>
166
-
);
167
-
}
+9
src/routes/rnfgrertt/index.lazy.tsx
+9
src/routes/rnfgrertt/index.lazy.tsx
+418
src/routes/rnfgrertt/typing.lazy.tsx
+418
src/routes/rnfgrertt/typing.lazy.tsx
···
1
+
import { createLazyFileRoute } from "@tanstack/react-router";
2
+
import { useState, useEffect, useRef } from "preact/hooks";
3
+
import {
4
+
LineChart,
5
+
Line,
6
+
XAxis,
7
+
YAxis,
8
+
CartesianGrid,
9
+
Tooltip,
10
+
Legend,
11
+
} from "recharts";
12
+
13
+
// Type definitions
14
+
type TypingError = {
15
+
position: number;
16
+
timestamp: number;
17
+
expected: string;
18
+
actual: string;
19
+
};
20
+
21
+
type WPMDataPoint = {
22
+
time: number;
23
+
wpm: number;
24
+
errorsPerSecond: number;
25
+
};
26
+
27
+
type TypingStats = {
28
+
accuracy: string;
29
+
wpm: string;
30
+
time: string;
31
+
errorCount: number;
32
+
};
33
+
34
+
// Constants
35
+
const SAMPLE_TEXT = "The quick brown fox jumps over the lazy dog";
36
+
const UPDATE_INTERVAL = 500;
37
+
const CHART_MARGIN = { top: 5, right: 30, left: 20, bottom: 5 };
38
+
39
+
// Custom hooks
40
+
const useWpmTracker = (
41
+
startTime: number | null,
42
+
isFinished: boolean,
43
+
userInput: string,
44
+
) => {
45
+
const [wpmData, setWpmData] = useState<WPMDataPoint[]>([]);
46
+
const userInputRef = useRef(userInput);
47
+
const lastUpdateRef = useRef<number>(0);
48
+
const prevErrorsRef = useRef<number>(0);
49
+
50
+
useEffect(() => {
51
+
userInputRef.current = userInput;
52
+
if (userInputRef.current.length >= 5 && wpmData.length < 1) {
53
+
updateWPMData();
54
+
}
55
+
}, [userInput]);
56
+
57
+
const calculateMetrics = (currentTime: number) => {
58
+
if (startTime === null)
59
+
return { timeElapsed: 0, correctChars: 0, currentErrors: 0 };
60
+
const timeElapsed = (currentTime - startTime) / 1000;
61
+
const currentInput = userInputRef.current;
62
+
63
+
// Calculate correct characters and current errors
64
+
let correctChars = 0;
65
+
let currentErrors = 0;
66
+
67
+
for (let i = 0; i < currentInput.length; i++) {
68
+
if (currentInput[i] === SAMPLE_TEXT[i]) {
69
+
correctChars++;
70
+
} else {
71
+
currentErrors++;
72
+
}
73
+
}
74
+
75
+
return { timeElapsed, correctChars, currentErrors };
76
+
};
77
+
78
+
const updateWPMData = () => {
79
+
const now = Date.now();
80
+
const { timeElapsed, correctChars, currentErrors } = calculateMetrics(now);
81
+
82
+
// Calculate errors per second
83
+
const timeDiff = (now - (lastUpdateRef.current || now)) / 1000;
84
+
const errorDelta = currentErrors - prevErrorsRef.current;
85
+
const errorsPerSecond = timeDiff > 0 ? errorDelta / timeDiff : 0;
86
+
87
+
setWpmData((prev) => [
88
+
...prev,
89
+
{
90
+
time: Number(timeElapsed.toFixed(1)),
91
+
wpm: Math.round(correctChars / 5 / (timeElapsed / 60)),
92
+
errorsPerSecond: Number(errorsPerSecond.toFixed(1)),
93
+
},
94
+
]);
95
+
96
+
lastUpdateRef.current = now;
97
+
prevErrorsRef.current = currentErrors;
98
+
};
99
+
100
+
useEffect(() => {
101
+
if (!startTime || isFinished) return;
102
+
103
+
// Subsequent updates at interval
104
+
const intervalId = setInterval(updateWPMData, UPDATE_INTERVAL);
105
+
106
+
return () => clearInterval(intervalId);
107
+
}, [startTime, isFinished]);
108
+
109
+
const resetWpmData = () => setWpmData([]);
110
+
return { wpmData, resetWpmData };
111
+
};
112
+
113
+
const useErrorTracker = (errors: TypingError[]) => {
114
+
const [errorDistribution, setErrorDistribution] = useState<
115
+
{ position: number; count: number }[]
116
+
>([]);
117
+
118
+
useEffect(() => {
119
+
const distribution = new Array(SAMPLE_TEXT.length).fill(0);
120
+
errors.forEach(({ position }) => distribution[position]++);
121
+
setErrorDistribution(
122
+
distribution
123
+
.map((count, position) => ({ position, count }))
124
+
.filter((d) => d.count > 0),
125
+
);
126
+
}, [errors]);
127
+
128
+
return errorDistribution;
129
+
};
130
+
131
+
const useTypingTest = () => {
132
+
const [state, setState] = useState({
133
+
userInput: "",
134
+
isFinished: false,
135
+
startTime: null as number | null,
136
+
endTime: null as number | null,
137
+
errors: [] as TypingError[],
138
+
});
139
+
140
+
const handleInput = (input: string) => {
141
+
if (state.isFinished) return;
142
+
143
+
const newInput = input.slice(0, SAMPLE_TEXT.length);
144
+
const newErrors = [...state.errors];
145
+
146
+
// Track new errors
147
+
if (newInput.length > state.userInput.length) {
148
+
const newCharIndex = newInput.length - 1;
149
+
if (newInput[newCharIndex] !== SAMPLE_TEXT[newCharIndex]) {
150
+
newErrors.push({
151
+
position: newCharIndex,
152
+
timestamp: Date.now(),
153
+
expected: SAMPLE_TEXT[newCharIndex],
154
+
actual: newInput[newCharIndex],
155
+
});
156
+
}
157
+
}
158
+
159
+
setState((prev) => ({
160
+
...prev,
161
+
userInput: newInput,
162
+
errors: newErrors,
163
+
startTime: prev.startTime || (newInput.length > 0 ? Date.now() : null),
164
+
isFinished: newInput.length === SAMPLE_TEXT.length,
165
+
endTime:
166
+
newInput.length === SAMPLE_TEXT.length ? Date.now() : prev.endTime,
167
+
}));
168
+
};
169
+
170
+
const resetTest = () => {
171
+
setState({
172
+
userInput: "",
173
+
isFinished: false,
174
+
startTime: null,
175
+
endTime: null,
176
+
errors: [],
177
+
});
178
+
};
179
+
180
+
return { ...state, handleInput, resetTest };
181
+
};
182
+
183
+
// UI Components
184
+
const ResultsView = ({
185
+
stats,
186
+
wpmData,
187
+
resetTest,
188
+
}: {
189
+
stats: TypingStats;
190
+
wpmData: WPMDataPoint[];
191
+
resetTest: () => void;
192
+
}) => (
193
+
<div className="m-auto px-4 py-16 flex-1 max-w-screen-sm text-center bg-card space-y-4 p-6 rounded-lg">
194
+
<h2 className="text-2xl font-bold mb-4">Test Results</h2>
195
+
<StatsGrid stats={stats} />
196
+
<PerformanceChart wpmData={wpmData} />
197
+
<ResetButton onClick={resetTest} />
198
+
</div>
199
+
);
200
+
201
+
const StatsGrid = ({ stats }: { stats: TypingStats }) => (
202
+
<div className="grid grid-cols-4 gap-4">
203
+
{Object.entries(stats).map(([key, value]) => (
204
+
<StatBox key={key} label={key} value={value} />
205
+
))}
206
+
</div>
207
+
);
208
+
209
+
const StatBox = ({
210
+
label,
211
+
value,
212
+
}: {
213
+
label: string;
214
+
value: string | number;
215
+
}) => (
216
+
<div className="bg-muted p-4 rounded">
217
+
<div className="text-gray-500 text-sm">{label}</div>
218
+
<div className="text-3xl font-bold">{value}</div>
219
+
<div className="text-gray-500 text-sm">{getUnit(label)}</div>
220
+
</div>
221
+
);
222
+
223
+
const PerformanceChart = ({ wpmData }: { wpmData: WPMDataPoint[] }) => (
224
+
<div className="mt-8 flex flex-col justify-center items-center">
225
+
<h3 className="text-xl font-bold mb-4">Performance Over Time</h3>
226
+
<LineChart width={650} height={300} data={wpmData} margin={CHART_MARGIN}>
227
+
<CartesianGrid strokeDasharray="3 3" />
228
+
<XAxis
229
+
dataKey="time"
230
+
type="number"
231
+
label={{ value: "Time (seconds)", position: "bottom" }}
232
+
domain={[0, "auto"]}
233
+
/>
234
+
<YAxis
235
+
yAxisId="left"
236
+
label={{ value: "WPM", angle: -90, position: "insideLeft" }}
237
+
/>
238
+
<YAxis
239
+
yAxisId="right"
240
+
orientation="right"
241
+
label={{ value: "Errors/sec", angle: 90, position: "insideRight" }}
242
+
domain={[0, Math.max(...wpmData.map((d) => d.errorsPerSecond), 1)]}
243
+
/>
244
+
<Tooltip content={<CustomTooltip />} />
245
+
<Legend />
246
+
<Line
247
+
yAxisId="left"
248
+
type="monotone"
249
+
dataKey="wpm"
250
+
stroke="#8884d8"
251
+
name="WPM"
252
+
strokeWidth={2}
253
+
dot={false}
254
+
/>
255
+
<Line
256
+
yAxisId="right"
257
+
type="monotone"
258
+
dataKey="errorsPerSecond"
259
+
stroke="#ff4757"
260
+
name="Errors/sec"
261
+
strokeWidth={2}
262
+
dot={false}
263
+
/>
264
+
</LineChart>
265
+
</div>
266
+
);
267
+
268
+
const CustomTooltip = ({ active, payload }: any) => {
269
+
if (active && payload?.length) {
270
+
const data = payload[0].payload;
271
+
return (
272
+
<div className="bg-white p-2 border rounded shadow">
273
+
<p className="text-sm">Time: {data.time}s</p>
274
+
<p className="text-sm text-blue-500">WPM: {data.wpm}</p>
275
+
<p className="text-sm text-red-500">
276
+
Errors/sec: {data.errorsPerSecond}
277
+
</p>
278
+
</div>
279
+
);
280
+
}
281
+
return null;
282
+
};
283
+
284
+
const TypingArea = ({
285
+
userInput,
286
+
handleInput,
287
+
}: {
288
+
userInput: string;
289
+
handleInput: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
290
+
}) => (
291
+
<div className="m-auto flex-1 relative p-4 bg-muted rounded-lg h-min max-w-xl">
292
+
<TextDisplay userInput={userInput} />
293
+
<textarea
294
+
className="absolute top-0 left-0 w-full h-full p-4 resize-none bg-transparent text-transparent caret-transparent outline-none"
295
+
value={userInput}
296
+
onChange={handleInput}
297
+
autoFocus
298
+
/>
299
+
</div>
300
+
);
301
+
302
+
const TextDisplay = ({ userInput }: { userInput: string }) => (
303
+
<div className="whitespace-pre-wrap break-words">
304
+
{SAMPLE_TEXT.split("").map((char, i) => {
305
+
const status =
306
+
i < userInput.length
307
+
? userInput[i] === char
308
+
? "text-green-500"
309
+
: "text-red-500 underline"
310
+
: "";
311
+
return (
312
+
<span
313
+
key={i}
314
+
className={`${status} ${i === userInput.length ? "bg-muted animate-blink" : ""}`}
315
+
>
316
+
{char}
317
+
</span>
318
+
);
319
+
})}
320
+
</div>
321
+
);
322
+
323
+
// Helper components
324
+
325
+
const CustomErrorDot = (props: any) =>
326
+
props.payload.errors >= 1 ? (
327
+
<circle cx={props.cx} cy={props.cy} r={4} fill="#ff4757" stroke="none" />
328
+
) : null;
329
+
330
+
const ResetButton = ({ onClick }: { onClick: () => void }) => (
331
+
<button
332
+
onClick={onClick}
333
+
className="mt-6 px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
334
+
>
335
+
Try Again
336
+
</button>
337
+
);
338
+
339
+
// Helper functions
340
+
const getUnit = (label: string) => {
341
+
switch (label) {
342
+
case "accuracy":
343
+
return "%";
344
+
case "wpm":
345
+
return "WPM";
346
+
case "time":
347
+
return "seconds";
348
+
case "errorCount":
349
+
return "total";
350
+
default:
351
+
return "";
352
+
}
353
+
};
354
+
355
+
const calculateStats = (
356
+
userInput: string,
357
+
errors: TypingError[],
358
+
startTime: number,
359
+
endTime: number,
360
+
): TypingStats => {
361
+
const timeElapsed = (endTime - startTime) / 1000;
362
+
const correctChars = userInput
363
+
.split("")
364
+
.filter((char, i) => char === SAMPLE_TEXT[i]).length;
365
+
366
+
return {
367
+
accuracy: ((correctChars / SAMPLE_TEXT.length) * 100).toFixed(1),
368
+
wpm: (correctChars / 5 / (timeElapsed / 60)).toFixed(1),
369
+
time: timeElapsed.toFixed(1),
370
+
errorCount: errors.length,
371
+
};
372
+
};
373
+
374
+
// Main component
375
+
export const Route = createLazyFileRoute("/rnfgrertt/typing")({
376
+
component: () => {
377
+
const {
378
+
userInput,
379
+
isFinished,
380
+
startTime,
381
+
endTime,
382
+
errors,
383
+
handleInput,
384
+
resetTest,
385
+
} = useTypingTest();
386
+
const { wpmData, resetWpmData } = useWpmTracker(
387
+
startTime,
388
+
isFinished,
389
+
userInput,
390
+
);
391
+
//const errorDistribution = useErrorTracker(errors);
392
+
393
+
const resetAll = () => {
394
+
resetTest();
395
+
resetWpmData();
396
+
};
397
+
398
+
const stats =
399
+
startTime && endTime
400
+
? calculateStats(userInput, errors, startTime, endTime)
401
+
: ({} as TypingStats);
402
+
403
+
return (
404
+
<main className="h-screen relative max-h-[calc(100vh-5rem)] flex">
405
+
{isFinished ? (
406
+
<ResultsView stats={stats} wpmData={wpmData} resetTest={resetAll} />
407
+
) : (
408
+
<TypingArea
409
+
userInput={userInput}
410
+
handleInput={(e) =>
411
+
handleInput((e.target as HTMLTextAreaElement).value)
412
+
}
413
+
/>
414
+
)}
415
+
</main>
416
+
);
417
+
},
418
+
});
+69
-64
tailwind.config.js
+69
-64
tailwind.config.js
···
1
+
import { fontFamily } from "tailwindcss/defaultTheme";
2
+
1
3
/** @type {import('tailwindcss').Config} */
2
-
module.exports = {
3
-
darkMode: ["class"],
4
-
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
5
-
theme: {
6
-
extend: {
7
-
borderRadius: {
8
-
lg: 'var(--radius)',
9
-
md: 'calc(var(--radius) - 2px)',
10
-
sm: 'calc(var(--radius) - 4px)'
11
-
},
12
-
colors: {
13
-
background: 'hsl(var(--background))',
14
-
foreground: 'hsl(var(--foreground))',
15
-
card: {
16
-
DEFAULT: 'hsl(var(--card))',
17
-
foreground: 'hsl(var(--card-foreground))'
18
-
},
19
-
popover: {
20
-
DEFAULT: 'hsl(var(--popover))',
21
-
foreground: 'hsl(var(--popover-foreground))'
22
-
},
23
-
primary: {
24
-
DEFAULT: 'hsl(var(--primary))',
25
-
foreground: 'hsl(var(--primary-foreground))'
26
-
},
27
-
secondary: {
28
-
DEFAULT: 'hsl(var(--secondary))',
29
-
foreground: 'hsl(var(--secondary-foreground))'
30
-
},
31
-
muted: {
32
-
DEFAULT: 'hsl(var(--muted))',
33
-
foreground: 'hsl(var(--muted-foreground))'
34
-
},
35
-
accent: {
36
-
DEFAULT: 'hsl(var(--accent))',
37
-
foreground: 'hsl(var(--accent-foreground))'
38
-
},
39
-
destructive: {
40
-
DEFAULT: 'hsl(var(--destructive))',
41
-
foreground: 'hsl(var(--destructive-foreground))'
42
-
},
43
-
border: 'hsl(var(--border))',
44
-
input: 'hsl(var(--input))',
45
-
ring: 'hsl(var(--ring))',
46
-
chart: {
47
-
'1': 'hsl(var(--chart-1))',
48
-
'2': 'hsl(var(--chart-2))',
49
-
'3': 'hsl(var(--chart-3))',
50
-
'4': 'hsl(var(--chart-4))',
51
-
'5': 'hsl(var(--chart-5))'
52
-
},
53
-
sidebar: {
54
-
DEFAULT: 'hsl(var(--sidebar-background))',
55
-
foreground: 'hsl(var(--sidebar-foreground))',
56
-
primary: 'hsl(var(--sidebar-primary))',
57
-
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
58
-
accent: 'hsl(var(--sidebar-accent))',
59
-
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
60
-
border: 'hsl(var(--sidebar-border))',
61
-
ring: 'hsl(var(--sidebar-ring))'
62
-
}
63
-
}
64
-
}
4
+
export const darkMode = ["class"];
5
+
export const content = ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"];
6
+
export const theme = {
7
+
extend: {
8
+
fontFamily: {
9
+
sans: ["var(--font-sans)", ...fontFamily.sans],
10
+
serif: ["var(--font-serif)", ...fontFamily.serif],
11
+
mono: ["var(--font-mono)", ...fontFamily.mono],
12
+
},
13
+
borderRadius: {
14
+
lg: "var(--radius)",
15
+
md: "calc(var(--radius) - 2px)",
16
+
sm: "calc(var(--radius) - 4px)",
17
+
},
18
+
colors: {
19
+
background: "hsl(var(--background))",
20
+
foreground: "hsl(var(--foreground))",
21
+
card: {
22
+
DEFAULT: "hsl(var(--card))",
23
+
foreground: "hsl(var(--card-foreground))",
24
+
},
25
+
popover: {
26
+
DEFAULT: "hsl(var(--popover))",
27
+
foreground: "hsl(var(--popover-foreground))",
28
+
},
29
+
primary: {
30
+
DEFAULT: "hsl(var(--primary))",
31
+
foreground: "hsl(var(--primary-foreground))",
32
+
},
33
+
secondary: {
34
+
DEFAULT: "hsl(var(--secondary))",
35
+
foreground: "hsl(var(--secondary-foreground))",
36
+
},
37
+
muted: {
38
+
DEFAULT: "hsl(var(--muted))",
39
+
foreground: "hsl(var(--muted-foreground))",
40
+
},
41
+
accent: {
42
+
DEFAULT: "hsl(var(--accent))",
43
+
foreground: "hsl(var(--accent-foreground))",
44
+
},
45
+
destructive: {
46
+
DEFAULT: "hsl(var(--destructive))",
47
+
foreground: "hsl(var(--destructive-foreground))",
48
+
},
49
+
border: "hsl(var(--border))",
50
+
input: "hsl(var(--input))",
51
+
ring: "hsl(var(--ring))",
52
+
chart: {
53
+
1: "hsl(var(--chart-1))",
54
+
2: "hsl(var(--chart-2))",
55
+
3: "hsl(var(--chart-3))",
56
+
4: "hsl(var(--chart-4))",
57
+
5: "hsl(var(--chart-5))",
58
+
},
59
+
sidebar: {
60
+
DEFAULT: "hsl(var(--sidebar-background))",
61
+
foreground: "hsl(var(--sidebar-foreground))",
62
+
primary: "hsl(var(--sidebar-primary))",
63
+
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
64
+
accent: "hsl(var(--sidebar-accent))",
65
+
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
66
+
border: "hsl(var(--sidebar-border))",
67
+
ring: "hsl(var(--sidebar-ring))",
68
+
},
69
+
},
65
70
},
66
-
plugins: [require("tailwindcss-animate")],
67
71
};
72
+
export const plugins = [require("tailwindcss-animate")];