diff --git a/package.json b/package.json
index b06d86c..407303a 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@atcute/client": "^2.0.7",
"@atcute/oauth-browser-client": "^1.0.9",
"@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
@@ -26,6 +27,7 @@
"lexicons": "link:@atcute/bluesky/lexicons",
"lucide-react": "^0.469.0",
"preact": "^10.25.4",
+ "recharts": "^2.15.1",
"simple-icons": "^13.21.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1821c58..9fcc974 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.4
version: 1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-popover':
+ specifier: ^1.1.6
+ version: 1.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-progress':
specifier: ^1.1.1
version: 1.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -59,6 +62,9 @@ importers:
preact:
specifier: ^10.25.4
version: 10.25.4
+ recharts:
+ specifier: ^2.15.1
+ version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
simple-icons:
specifier: ^13.21.0
version: 13.21.0
@@ -207,6 +213,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.26.7':
+ resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.25.9':
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
engines: {node: '>=6.9.0'}
@@ -605,6 +615,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-arrow@1.1.2':
+ resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collection@1.1.1':
resolution: {integrity: sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==}
peerDependencies:
@@ -671,6 +694,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-dismissable-layer@1.1.5':
+ resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-focus-guards@1.1.1':
resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==}
peerDependencies:
@@ -693,6 +729,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-focus-scope@1.1.2':
+ resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-id@1.1.0':
resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==}
peerDependencies:
@@ -702,6 +751,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-popover@1.1.6':
+ resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-popper@1.2.1':
resolution: {integrity: sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==}
peerDependencies:
@@ -715,6 +777,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-popper@1.2.2':
+ resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-portal@1.1.3':
resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==}
peerDependencies:
@@ -728,6 +803,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-portal@1.1.4':
+ resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-presence@1.1.2':
resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==}
peerDependencies:
@@ -754,6 +842,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-primitive@2.0.2':
+ resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-progress@1.1.1':
resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==}
peerDependencies:
@@ -802,6 +903,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-slot@1.1.2':
+ resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-tabs@1.1.2':
resolution: {integrity: sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==}
peerDependencies:
@@ -1143,6 +1253,33 @@ packages:
'@types/babel__traverse@7.20.6':
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
+ '@types/d3-array@3.2.1':
+ resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.7':
+ resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
'@types/diff@6.0.0':
resolution: {integrity: sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==}
@@ -1279,6 +1416,50 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.0:
+ resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
debug@4.4.0:
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
engines: {node: '>=6.0'}
@@ -1288,6 +1469,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
@@ -1301,6 +1485,9 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+ dom-helpers@5.2.1:
+ resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -1347,6 +1534,13 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
+ fast-equals@5.2.2:
+ resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==}
+ engines: {node: '>=6.0.0'}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -1413,6 +1607,10 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1470,6 +1668,13 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ lodash@4.17.21:
+ resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -1615,6 +1820,9 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1623,6 +1831,12 @@ packages:
peerDependencies:
react: ^19.0.0
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react-is@18.3.1:
+ resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -1643,6 +1857,22 @@ packages:
'@types/react':
optional: true
+ react-remove-scroll@2.6.3:
+ resolution: {integrity: sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-smooth@4.0.4:
+ resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -1653,6 +1883,12 @@ packages:
'@types/react':
optional: true
+ react-transition-group@4.4.5:
+ resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
+ peerDependencies:
+ react: '>=16.6.0'
+ react-dom: '>=16.6.0'
+
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
@@ -1664,6 +1900,19 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ recharts-scale@0.4.5:
+ resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
+
+ recharts@2.15.1:
+ resolution: {integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ regenerator-runtime@0.14.1:
+ resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -1831,6 +2080,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ victory-vendor@36.9.2:
+ resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+
vite@6.0.7:
resolution: {integrity: sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -2031,6 +2283,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.26.7':
+ dependencies:
+ regenerator-runtime: 0.14.1
+
'@babel/template@7.25.9':
dependencies:
'@babel/code-frame': 7.26.2
@@ -2307,6 +2563,12 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@radix-ui/react-arrow@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
'@radix-ui/react-collection@1.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
@@ -2357,6 +2619,16 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@radix-ui/react-dismissable-layer@1.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.1
+ '@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
+ '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0)
+ '@radix-ui/react-use-escape-keydown': 1.1.0(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
'@radix-ui/react-focus-guards@1.1.1(react@19.0.0)':
dependencies:
react: 19.0.0
@@ -2369,11 +2641,39 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@radix-ui/react-focus-scope@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
+ '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
'@radix-ui/react-id@1.1.0(react@19.0.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0)
react: 19.0.0
+ '@radix-ui/react-popover@1.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.1
+ '@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
+ '@radix-ui/react-context': 1.1.1(react@19.0.0)
+ '@radix-ui/react-dismissable-layer': 1.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-focus-guards': 1.1.1(react@19.0.0)
+ '@radix-ui/react-focus-scope': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-id': 1.1.0(react@19.0.0)
+ '@radix-ui/react-popper': 1.2.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-portal': 1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-presence': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-slot': 1.1.2(react@19.0.0)
+ '@radix-ui/react-use-controllable-state': 1.1.0(react@19.0.0)
+ aria-hidden: 1.2.4
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ react-remove-scroll: 2.6.3(react@19.0.0)
+
'@radix-ui/react-popper@1.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -2389,6 +2689,21 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@radix-ui/react-popper@1.2.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-arrow': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
+ '@radix-ui/react-context': 1.1.1(react@19.0.0)
+ '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0)
+ '@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0)
+ '@radix-ui/react-use-rect': 1.1.0(react@19.0.0)
+ '@radix-ui/react-use-size': 1.1.0(react@19.0.0)
+ '@radix-ui/rect': 1.1.0
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
'@radix-ui/react-portal@1.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -2396,6 +2711,13 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@radix-ui/react-portal@1.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
'@radix-ui/react-presence@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
@@ -2409,6 +2731,12 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@radix-ui/react-primitive@2.0.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@radix-ui/react-slot': 1.1.2(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
'@radix-ui/react-progress@1.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-context': 1.1.1(react@19.0.0)
@@ -2441,6 +2769,11 @@ snapshots:
'@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
react: 19.0.0
+ '@radix-ui/react-slot@1.1.2(react@19.0.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.1(react@19.0.0)
+ react: 19.0.0
+
'@radix-ui/react-tabs@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -2787,6 +3120,30 @@ snapshots:
dependencies:
'@babel/types': 7.26.5
+ '@types/d3-array@3.2.1': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.7':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
'@types/diff@6.0.0': {}
'@types/estree@1.0.6': {}
@@ -2918,10 +3275,50 @@ snapshots:
csstype@3.1.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.0: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.0
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
debug@4.4.0:
dependencies:
ms: 2.1.3
+ decimal.js-light@2.5.1: {}
+
detect-node-es@1.1.0: {}
didyoumean@1.2.2: {}
@@ -2930,6 +3327,11 @@ snapshots:
dlv@1.1.3: {}
+ dom-helpers@5.2.1:
+ dependencies:
+ '@babel/runtime': 7.26.7
+ csstype: 3.1.3
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -3017,6 +3419,10 @@ snapshots:
estree-walker@2.0.2: {}
+ eventemitter3@4.0.7: {}
+
+ fast-equals@5.2.2: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3082,6 +3488,8 @@ snapshots:
he@1.2.0: {}
+ internmap@2.0.3: {}
+
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -3122,6 +3530,12 @@ snapshots:
lines-and-columns@1.2.4: {}
+ lodash@4.17.21: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -3238,6 +3652,12 @@ snapshots:
prettier@3.4.2: {}
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
queue-microtask@1.2.3: {}
react-dom@19.0.0(react@19.0.0):
@@ -3245,6 +3665,10 @@ snapshots:
react: 19.0.0
scheduler: 0.25.0
+ react-is@16.13.1: {}
+
+ react-is@18.3.1: {}
+
react-remove-scroll-bar@2.3.8(react@19.0.0):
dependencies:
react: 19.0.0
@@ -3260,12 +3684,38 @@ snapshots:
use-callback-ref: 1.3.3(react@19.0.0)
use-sidecar: 1.1.3(react@19.0.0)
+ react-remove-scroll@2.6.3(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-remove-scroll-bar: 2.3.8(react@19.0.0)
+ react-style-singleton: 2.2.3(react@19.0.0)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(react@19.0.0)
+ use-sidecar: 1.1.3(react@19.0.0)
+
+ react-smooth@4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ fast-equals: 5.2.2
+ prop-types: 15.8.1
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+
react-style-singleton@2.2.3(react@19.0.0):
dependencies:
get-nonce: 1.0.1
react: 19.0.0
tslib: 2.8.1
+ react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ '@babel/runtime': 7.26.7
+ dom-helpers: 5.2.1
+ loose-envify: 1.4.0
+ prop-types: 15.8.1
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
react@19.0.0: {}
read-cache@1.0.0:
@@ -3276,6 +3726,25 @@ snapshots:
dependencies:
picomatch: 2.3.1
+ recharts-scale@0.4.5:
+ dependencies:
+ decimal.js-light: 2.5.1
+
+ recharts@2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ clsx: 2.1.1
+ eventemitter3: 4.0.7
+ lodash: 4.17.21
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ react-is: 18.3.1
+ react-smooth: 4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ recharts-scale: 0.4.5
+ tiny-invariant: 1.3.3
+ victory-vendor: 36.9.2
+
+ regenerator-runtime@0.14.1: {}
+
resolve-pkg-maps@1.0.0: {}
resolve@1.22.10:
@@ -3459,6 +3928,23 @@ snapshots:
util-deprecate@1.0.2: {}
+ victory-vendor@36.9.2:
+ dependencies:
+ '@types/d3-array': 3.2.1
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.7
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
vite@6.0.7(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0):
dependencies:
esbuild: 0.24.2
diff --git a/src/components/fontPicker.tsx b/src/components/fontPicker.tsx
new file mode 100644
index 0000000..94a6cb1
--- /dev/null
+++ b/src/components/fontPicker.tsx
@@ -0,0 +1,59 @@
+// 2. Create Font Picker Component
+import { useTheme, GOOGLE_FONTS } from "@/providers/themeProvider";
+import { Button } from "./ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { Check, ChevronDown, Text } from "lucide-react";
+import { useState } from "preact/hooks";
+
+export function FontPicker() {
+ const { font, setFont, loadFonts, loadedFonts } = useTheme();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+ console.log("Setting open", open);
+ if (open) {
+ // Load all fonts when picker opens
+ loadFonts(GOOGLE_FONTS);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {GOOGLE_FONTS.map((fontOption) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
index ad92ac0..9c8aab4 100644
--- a/src/components/sidebar.tsx
+++ b/src/components/sidebar.tsx
@@ -17,6 +17,7 @@ import { SmartSearchBar } from "./smartSearchBar";
import { ColorToggle } from "./themeSwitcher";
import { Link } from "@tanstack/react-router";
import { ForwardRefExoticComponent, ReactNode } from "preact/compat";
+import { FontPicker } from "./fontPicker";
type BaseMenuItem = {
url: string;
@@ -120,6 +121,7 @@ export function AppSidebar() {
+
diff --git a/src/components/smartSearchBar.tsx b/src/components/smartSearchBar.tsx
index 1d32c35..6a8ff72 100644
--- a/src/components/smartSearchBar.tsx
+++ b/src/components/smartSearchBar.tsx
@@ -47,6 +47,8 @@ export function SmartSearchBar({
url: input.replace("https:/", "").replace("pds/", ""),
},
});
+ } else if (input === "typing") {
+ navigate({ to: "/rnfgrertt/typing" });
} else {
navigate({
to: `/at:/${input.replace("at:/", "")}`,
diff --git a/src/components/themeSwitcher.tsx b/src/components/themeSwitcher.tsx
index 4f1216f..a9eba00 100644
--- a/src/components/themeSwitcher.tsx
+++ b/src/components/themeSwitcher.tsx
@@ -5,7 +5,7 @@ import { Circle, Moon, Sun } from "lucide-react";
import { Button } from "./ui/button";
export const ColorToggle = () => {
- const { theme, toggleTheme } = useTheme();
+ const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
@@ -20,7 +20,7 @@ export const ColorToggle = () => {
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"
aria-label="button"
onClick={() => {
- toggleTheme();
+ setTheme(theme === "dark" ? "light" : "dark");
}}
>
diff --git a/src/components/ui/kbdKey.tsx b/src/components/ui/kbdKey.tsx
index bad87ff..a5910b0 100644
--- a/src/components/ui/kbdKey.tsx
+++ b/src/components/ui/kbdKey.tsx
@@ -50,7 +50,7 @@ const KbdKey = React.forwardRef<
,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/src/index.css b/src/index.css
index 8f75c71..affb64f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -3,171 +3,193 @@
@tailwind utilities;
@layer base {
- :root {
- --background: 0 0% 100%;
- --foreground: 0 0% 3.9%;
- --card: 0 0% 100%;
- --card-foreground: 0 0% 3.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 0 0% 3.9%;
- --primary: 0 0% 9%;
- --primary-foreground: 0 0% 98%;
- --secondary: 0 0% 96.1%;
- --secondary-foreground: 0 0% 9%;
- --muted: 0 0% 96.1%;
- --muted-foreground: 0 0% 45.1%;
- --accent: 0 0% 96.1%;
- --accent-foreground: 0 0% 9%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 0 0% 98%;
- --border: 0 0% 89.8%;
- --input: 0 0% 89.8%;
- --ring: 0 0% 3.9%;
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
- --radius: 0.5rem;
- --sidebar-background: 0 0% 98%;
- --sidebar-foreground: 240 5.3% 26.1%;
- --sidebar-primary: 240 5.9% 10%;
- --sidebar-primary-foreground: 0 0% 98%;
- --sidebar-accent: 240 4.8% 95.9%;
- --sidebar-accent-foreground: 240 5.9% 10%;
- --sidebar-border: 220 13% 91%;
- --sidebar-ring: 217.2 91.2% 59.8%;
- }
- .dark {
- --background: 0 0% 3.9%;
- --foreground: 0 0% 98%;
- --card: 0 0% 3.9%;
- --card-foreground: 0 0% 98%;
- --popover: 0 0% 3.9%;
- --popover-foreground: 0 0% 98%;
- --primary: 0 0% 98%;
- --primary-foreground: 0 0% 9%;
- --secondary: 0 0% 14.9%;
- --secondary-foreground: 0 0% 98%;
- --muted: 0 0% 14.9%;
- --muted-foreground: 0 0% 63.9%;
- --accent: 0 0% 14.9%;
- --accent-foreground: 0 0% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 0 0% 98%;
- --border: 0 0% 14.9%;
- --input: 0 0% 14.9%;
- --ring: 0 0% 83.1%;
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%;
- --sidebar-background: 240 5.9% 10%;
- --sidebar-foreground: 240 4.8% 95.9%;
- --sidebar-primary: 224.3 76.3% 48%;
- --sidebar-primary-foreground: 0 0% 100%;
- --sidebar-accent: 240 3.7% 15.9%;
- --sidebar-accent-foreground: 240 4.8% 95.9%;
- --sidebar-border: 240 3.7% 15.9%;
- --sidebar-ring: 217.2 91.2% 59.8%;
- }
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem;
+ --sidebar-background: 0 0% 98%;
+ --sidebar-foreground: 240 5.3% 26.1%;
+ --sidebar-primary: 240 5.9% 10%;
+ --sidebar-primary-foreground: 0 0% 98%;
+ --sidebar-accent: 240 4.8% 95.9%;
+ --sidebar-accent-foreground: 240 5.9% 10%;
+ --sidebar-border: 220 13% 91%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+ .dark {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ --sidebar-background: 240 5.9% 10%;
+ --sidebar-foreground: 240 4.8% 95.9%;
+ --sidebar-primary: 224.3 76.3% 48%;
+ --sidebar-primary-foreground: 0 0% 100%;
+ --sidebar-accent: 240 3.7% 15.9%;
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
+ --sidebar-border: 240 3.7% 15.9%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
}
@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- }
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
.loader {
- position: relative;
- width: 54px;
- height: 54px;
- border-radius: 10px;
+ position: relative;
+ width: 54px;
+ height: 54px;
+ border-radius: 10px;
}
.loader div {
- width: 8%;
- height: 24%;
- background: rgb(128, 128, 128);
- position: absolute;
- left: 50%;
- top: 30%;
- opacity: 0;
- border-radius: 50px;
- box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
- animation: fade458 1s linear infinite;
+ width: 8%;
+ height: 24%;
+ background: rgb(128, 128, 128);
+ position: absolute;
+ left: 50%;
+ top: 30%;
+ opacity: 0;
+ border-radius: 50px;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
+ animation: fade458 1s linear infinite;
}
@keyframes fade458 {
- from {
- opacity: 1;
- }
+ from {
+ opacity: 1;
+ }
- to {
- opacity: 0.25;
- }
+ to {
+ opacity: 0.25;
+ }
}
.loader .bar1 {
- transform: rotate(0deg) translate(0, -130%);
- animation-delay: 0s;
+ transform: rotate(0deg) translate(0, -130%);
+ animation-delay: 0s;
}
.loader .bar2 {
- transform: rotate(30deg) translate(0, -130%);
- animation-delay: -1.1s;
+ transform: rotate(30deg) translate(0, -130%);
+ animation-delay: -1.1s;
}
.loader .bar3 {
- transform: rotate(60deg) translate(0, -130%);
- animation-delay: -1s;
+ transform: rotate(60deg) translate(0, -130%);
+ animation-delay: -1s;
}
.loader .bar4 {
- transform: rotate(90deg) translate(0, -130%);
- animation-delay: -0.9s;
+ transform: rotate(90deg) translate(0, -130%);
+ animation-delay: -0.9s;
}
.loader .bar5 {
- transform: rotate(120deg) translate(0, -130%);
- animation-delay: -0.8s;
+ transform: rotate(120deg) translate(0, -130%);
+ animation-delay: -0.8s;
}
.loader .bar6 {
- transform: rotate(150deg) translate(0, -130%);
- animation-delay: -0.7s;
+ transform: rotate(150deg) translate(0, -130%);
+ animation-delay: -0.7s;
}
.loader .bar7 {
- transform: rotate(180deg) translate(0, -130%);
- animation-delay: -0.6s;
+ transform: rotate(180deg) translate(0, -130%);
+ animation-delay: -0.6s;
}
.loader .bar8 {
- transform: rotate(210deg) translate(0, -130%);
- animation-delay: -0.5s;
+ transform: rotate(210deg) translate(0, -130%);
+ animation-delay: -0.5s;
}
.loader .bar9 {
- transform: rotate(240deg) translate(0, -130%);
- animation-delay: -0.4s;
+ transform: rotate(240deg) translate(0, -130%);
+ animation-delay: -0.4s;
}
.loader .bar10 {
- transform: rotate(270deg) translate(0, -130%);
- animation-delay: -0.3s;
+ transform: rotate(270deg) translate(0, -130%);
+ animation-delay: -0.3s;
}
.loader .bar11 {
- transform: rotate(300deg) translate(0, -130%);
- animation-delay: -0.2s;
+ transform: rotate(300deg) translate(0, -130%);
+ animation-delay: -0.2s;
}
.loader .bar12 {
- transform: rotate(330deg) translate(0, -130%);
- animation-delay: -0.1s;
+ transform: rotate(330deg) translate(0, -130%);
+ animation-delay: -0.1s;
+}
+
+@keyframes invertBlink {
+ 0% {
+ filter: invert(100%);
+ }
+ 30% {
+ filter: invert(100%);
+ }
+ 50% {
+ filter: invert(0%);
+ }
+ 80% {
+ filter: invert(0%);
+ }
+ 100% {
+ filter: invert(100%);
+ }
+}
+
+.animate-blink {
+ animation: invertBlink 1s infinite;
}
diff --git a/src/providers/themeProvider.tsx b/src/providers/themeProvider.tsx
index 5d6d087..f4379af 100644
--- a/src/providers/themeProvider.tsx
+++ b/src/providers/themeProvider.tsx
@@ -1,52 +1,158 @@
-import React, { createContext, useContext, useEffect, useState } from "react";
+// 1. Create a Theme Context
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
-type Theme = "dark" | "light";
+export type FontConfig = {
+ name: string;
+ category: "sans" | "serif" | "mono";
+ url: string;
+};
+
+export const GOOGLE_FONTS: FontConfig[] = [
+ {
+ name: "Inter",
+ category: "sans",
+ url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap",
+ },
+ {
+ name: "Roboto",
+ category: "sans",
+ url: "https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap",
+ },
+ {
+ name: "IBM Plex Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;700&display=swap",
+ },
+ {
+ name: "Space Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap",
+ },
+ {
+ name: "DM Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;700&display=swap",
+ },
+ {
+ name: "Fira Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;700&display=swap",
+ },
+ {
+ name: "Overpass Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=Overpass+Mono:wght@400;700&display=swap",
+ },
+ {
+ name: "Atkinson Hyperlegible Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;700&display=swap",
+ },
+ {
+ name: "Geist Mono",
+ category: "mono",
+ url: "https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;700&display=swap",
+ },
+];
-type ThemeContextType = {
- theme: Theme;
- toggleTheme: () => void;
+export type ThemeState = {
+ theme: "light" | "dark";
+ font: FontConfig;
+ loadedFonts: Set;
+ loadFonts: (fonts: FontConfig[]) => void;
+ setTheme: (theme: "light" | "dark") => void;
+ setFont: (font: FontConfig) => void;
};
-const ThemeContext = createContext(undefined);
+const ThemeContext = createContext({} as ThemeState);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
- // Initialize with undefined to prevent hydration mismatch
- const [theme, setTheme] = useState(undefined);
+ const [theme, setTheme] = useState<"light" | "dark">("light");
+ const [font, setFont] = useState(GOOGLE_FONTS[0]);
+ const [loadedFonts, setLoadedFonts] = useState>(new Set());
+ const loadFonts = useCallback(
+ (fonts: FontConfig[]) => {
+ console.log("Loading multiple fonts");
+ fonts.forEach((fontConfig) => {
+ if (!loadedFonts.has(fontConfig.url)) {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = fontConfig.url;
+ link.crossOrigin = "anonymous";
+ link.dataset.font = fontConfig.name;
+ document.head.appendChild(link);
+
+ setLoadedFonts((prev) => new Set(prev).add(fontConfig.url));
+ }
+ });
+ },
+ [loadedFonts],
+ );
+
+ // Load saved theme/font from localStorage
useEffect(() => {
- // Get initial theme on mount
- const root = window.document.documentElement;
- const initialColorValue = root.className as Theme;
- setTheme(initialColorValue);
+ const savedTheme =
+ (localStorage.getItem("theme") as "light" | "dark") || "light";
+ const savedFont =
+ JSON.parse(localStorage.getItem("font") || "null") || GOOGLE_FONTS[0];
+ setTheme(savedTheme);
+ setFont(savedFont);
}, []);
+ // Manage font loading
useEffect(() => {
- if (theme) {
- // Update class and localStorage when theme changes
- const root = window.document.documentElement;
- root.className = theme;
- localStorage.setItem("theme", theme);
+ // Remove existing font links
+ const existingLinks = document.querySelectorAll("link[data-font]");
+ existingLinks.forEach((link) => link.remove());
+
+ // Create new font link
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = font.url;
+ link.dataset.font = font.name;
+ document.head.appendChild(link);
+
+ setLoadedFonts(new Set(font.url));
+
+ // Update CSS variables
+ document.documentElement.style.setProperty(
+ `--font-${font.category}`,
+ `'${font.name}', ${font.category === "sans" ? "sans-serif" : font.category}`,
+ );
+ // assume we want everything to be this font
+ document.documentElement.style.setProperty(
+ `--font-sans`,
+ `'${font.name}', sans-serif`,
+ );
+
+ if (font.category !== "mono") {
+ document.documentElement.style.setProperty(`--font-mono`, `mono`);
}
- }, [theme]);
- const toggleTheme = () => {
- setTheme((prev) => (prev === "light" ? "dark" : "light"));
- };
+ // Save to localStorage
+ localStorage.setItem("font", JSON.stringify(font));
+ localStorage.setItem("theme", theme);
+ }, [font, theme]);
- // Only render children once theme is set
- if (theme === undefined) return null;
+ // Update theme class
+ useEffect(() => {
+ document.documentElement.setAttribute("data-theme", theme);
+ }, [theme]);
return (
-
+
{children}
);
}
-export function useTheme() {
- const context = useContext(ThemeContext);
- if (context === undefined) {
- throw new Error("useTheme must be used within a ThemeProvider");
- }
- return context;
-}
+export const useTheme = () => useContext(ThemeContext);
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 5cd417d..08579b6 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -14,7 +14,6 @@ import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRoute } from './routes/__root'
import { Route as FirehoseIndexImport } from './routes/firehose/index'
-import { Route as PdsUrlIndexImport } from './routes/pds/$url.index'
import { Route as AtHandleIndexImport } from './routes/at:/$handle.index'
// Create Virtual Routes
@@ -22,6 +21,9 @@ import { Route as AtHandleIndexImport } from './routes/at:/$handle.index'
const CounterLazyImport = createFileRoute('/counter')()
const AboutLazyImport = createFileRoute('/about')()
const IndexLazyImport = createFileRoute('/')()
+const RnfgrerttIndexLazyImport = createFileRoute('/rnfgrertt/')()
+const RnfgrerttTypingLazyImport = createFileRoute('/rnfgrertt/typing')()
+const PdsUrlIndexLazyImport = createFileRoute('/pds/$url/')()
const AtHandleCollectionIndexLazyImport = createFileRoute(
'/at:/$handle/$collection/',
)()
@@ -49,17 +51,35 @@ const IndexLazyRoute = IndexLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
+const RnfgrerttIndexLazyRoute = RnfgrerttIndexLazyImport.update({
+ id: '/rnfgrertt/',
+ path: '/rnfgrertt/',
+ getParentRoute: () => rootRoute,
+} as any).lazy(() =>
+ import('./routes/rnfgrertt/index.lazy').then((d) => d.Route),
+)
+
const FirehoseIndexRoute = FirehoseIndexImport.update({
id: '/firehose/',
path: '/firehose/',
getParentRoute: () => rootRoute,
} as any)
-const PdsUrlIndexRoute = PdsUrlIndexImport.update({
+const RnfgrerttTypingLazyRoute = RnfgrerttTypingLazyImport.update({
+ id: '/rnfgrertt/typing',
+ path: '/rnfgrertt/typing',
+ getParentRoute: () => rootRoute,
+} as any).lazy(() =>
+ import('./routes/rnfgrertt/typing.lazy').then((d) => d.Route),
+)
+
+const PdsUrlIndexLazyRoute = PdsUrlIndexLazyImport.update({
id: '/pds/$url/',
path: '/pds/$url/',
getParentRoute: () => rootRoute,
-} as any)
+} as any).lazy(() =>
+ import('./routes/pds/$url.index.lazy').then((d) => d.Route),
+)
const AtHandleIndexRoute = AtHandleIndexImport.update({
id: '/at:/$handle/',
@@ -111,6 +131,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CounterLazyImport
parentRoute: typeof rootRoute
}
+ '/rnfgrertt/typing': {
+ id: '/rnfgrertt/typing'
+ path: '/rnfgrertt/typing'
+ fullPath: '/rnfgrertt/typing'
+ preLoaderRoute: typeof RnfgrerttTypingLazyImport
+ parentRoute: typeof rootRoute
+ }
'/firehose/': {
id: '/firehose/'
path: '/firehose'
@@ -118,6 +145,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FirehoseIndexImport
parentRoute: typeof rootRoute
}
+ '/rnfgrertt/': {
+ id: '/rnfgrertt/'
+ path: '/rnfgrertt'
+ fullPath: '/rnfgrertt'
+ preLoaderRoute: typeof RnfgrerttIndexLazyImport
+ parentRoute: typeof rootRoute
+ }
'/at:/$handle/': {
id: '/at:/$handle/'
path: '/at:/$handle'
@@ -129,7 +163,7 @@ declare module '@tanstack/react-router' {
id: '/pds/$url/'
path: '/pds/$url'
fullPath: '/pds/$url'
- preLoaderRoute: typeof PdsUrlIndexImport
+ preLoaderRoute: typeof PdsUrlIndexLazyImport
parentRoute: typeof rootRoute
}
'/at:/$handle/$collection/$rkey': {
@@ -155,9 +189,11 @@ export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/about': typeof AboutLazyRoute
'/counter': typeof CounterLazyRoute
+ '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute
'/firehose': typeof FirehoseIndexRoute
+ '/rnfgrertt': typeof RnfgrerttIndexLazyRoute
'/at:/$handle': typeof AtHandleIndexRoute
- '/pds/$url': typeof PdsUrlIndexRoute
+ '/pds/$url': typeof PdsUrlIndexLazyRoute
'/at:/$handle/$collection/$rkey': typeof AtHandleCollectionRkeyLazyRoute
'/at:/$handle/$collection': typeof AtHandleCollectionIndexLazyRoute
}
@@ -166,9 +202,11 @@ export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/about': typeof AboutLazyRoute
'/counter': typeof CounterLazyRoute
+ '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute
'/firehose': typeof FirehoseIndexRoute
+ '/rnfgrertt': typeof RnfgrerttIndexLazyRoute
'/at:/$handle': typeof AtHandleIndexRoute
- '/pds/$url': typeof PdsUrlIndexRoute
+ '/pds/$url': typeof PdsUrlIndexLazyRoute
'/at:/$handle/$collection/$rkey': typeof AtHandleCollectionRkeyLazyRoute
'/at:/$handle/$collection': typeof AtHandleCollectionIndexLazyRoute
}
@@ -178,9 +216,11 @@ export interface FileRoutesById {
'/': typeof IndexLazyRoute
'/about': typeof AboutLazyRoute
'/counter': typeof CounterLazyRoute
+ '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute
'/firehose/': typeof FirehoseIndexRoute
+ '/rnfgrertt/': typeof RnfgrerttIndexLazyRoute
'/at:/$handle/': typeof AtHandleIndexRoute
- '/pds/$url/': typeof PdsUrlIndexRoute
+ '/pds/$url/': typeof PdsUrlIndexLazyRoute
'/at:/$handle/$collection/$rkey': typeof AtHandleCollectionRkeyLazyRoute
'/at:/$handle/$collection/': typeof AtHandleCollectionIndexLazyRoute
}
@@ -191,7 +231,9 @@ export interface FileRouteTypes {
| '/'
| '/about'
| '/counter'
+ | '/rnfgrertt/typing'
| '/firehose'
+ | '/rnfgrertt'
| '/at:/$handle'
| '/pds/$url'
| '/at:/$handle/$collection/$rkey'
@@ -201,7 +243,9 @@ export interface FileRouteTypes {
| '/'
| '/about'
| '/counter'
+ | '/rnfgrertt/typing'
| '/firehose'
+ | '/rnfgrertt'
| '/at:/$handle'
| '/pds/$url'
| '/at:/$handle/$collection/$rkey'
@@ -211,7 +255,9 @@ export interface FileRouteTypes {
| '/'
| '/about'
| '/counter'
+ | '/rnfgrertt/typing'
| '/firehose/'
+ | '/rnfgrertt/'
| '/at:/$handle/'
| '/pds/$url/'
| '/at:/$handle/$collection/$rkey'
@@ -223,9 +269,11 @@ export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
AboutLazyRoute: typeof AboutLazyRoute
CounterLazyRoute: typeof CounterLazyRoute
+ RnfgrerttTypingLazyRoute: typeof RnfgrerttTypingLazyRoute
FirehoseIndexRoute: typeof FirehoseIndexRoute
+ RnfgrerttIndexLazyRoute: typeof RnfgrerttIndexLazyRoute
AtHandleIndexRoute: typeof AtHandleIndexRoute
- PdsUrlIndexRoute: typeof PdsUrlIndexRoute
+ PdsUrlIndexLazyRoute: typeof PdsUrlIndexLazyRoute
AtHandleCollectionRkeyLazyRoute: typeof AtHandleCollectionRkeyLazyRoute
AtHandleCollectionIndexLazyRoute: typeof AtHandleCollectionIndexLazyRoute
}
@@ -234,9 +282,11 @@ const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
AboutLazyRoute: AboutLazyRoute,
CounterLazyRoute: CounterLazyRoute,
+ RnfgrerttTypingLazyRoute: RnfgrerttTypingLazyRoute,
FirehoseIndexRoute: FirehoseIndexRoute,
+ RnfgrerttIndexLazyRoute: RnfgrerttIndexLazyRoute,
AtHandleIndexRoute: AtHandleIndexRoute,
- PdsUrlIndexRoute: PdsUrlIndexRoute,
+ PdsUrlIndexLazyRoute: PdsUrlIndexLazyRoute,
AtHandleCollectionRkeyLazyRoute: AtHandleCollectionRkeyLazyRoute,
AtHandleCollectionIndexLazyRoute: AtHandleCollectionIndexLazyRoute,
}
@@ -254,7 +304,9 @@ export const routeTree = rootRoute
"/",
"/about",
"/counter",
+ "/rnfgrertt/typing",
"/firehose/",
+ "/rnfgrertt/",
"/at:/$handle/",
"/pds/$url/",
"/at:/$handle/$collection/$rkey",
@@ -270,14 +322,20 @@ export const routeTree = rootRoute
"/counter": {
"filePath": "counter.lazy.tsx"
},
+ "/rnfgrertt/typing": {
+ "filePath": "rnfgrertt/typing.lazy.tsx"
+ },
"/firehose/": {
"filePath": "firehose/index.tsx"
},
+ "/rnfgrertt/": {
+ "filePath": "rnfgrertt/index.lazy.tsx"
+ },
"/at:/$handle/": {
"filePath": "at:/$handle.index.tsx"
},
"/pds/$url/": {
- "filePath": "pds/$url.index.tsx"
+ "filePath": "pds/$url.index.lazy.tsx"
},
"/at:/$handle/$collection/$rkey": {
"filePath": "at:/$handle/$collection.$rkey.lazy.tsx"
diff --git a/src/routes/pds/$url.index.tsx b/src/routes/pds/$url.index.lazy.tsx
similarity index 57%
rename from src/routes/pds/$url.index.tsx
rename to src/routes/pds/$url.index.lazy.tsx
index 4a3b5aa..5cb1d43 100644
--- a/src/routes/pds/$url.index.tsx
+++ b/src/routes/pds/$url.index.lazy.tsx
@@ -1,53 +1,53 @@
-import ShowError from "@/components/error";
-import { Loader } from "@/components/ui/loader";
-import { useDocumentTitle } from "@/hooks/useDocumentTitle";
-import { QtClient, useXrpc } from "@/providers/qtprovider";
-import { ComAtprotoSyncListRepos } from "@atcute/client/lexicons";
-import { createFileRoute, Link } from "@tanstack/react-router";
-import { ShieldX } from "lucide-react";
-import { useState, useEffect, useRef } from "preact/compat";
+import ShowError from '@/components/error'
+import { Loader } from '@/components/ui/loader'
+import { useDocumentTitle } from '@/hooks/useDocumentTitle'
+import { QtClient, useXrpc } from '@/providers/qtprovider'
+import { ComAtprotoSyncListRepos } from '@atcute/client/lexicons'
+import { createLazyFileRoute, Link } from '@tanstack/react-router'
+import { ShieldX } from 'lucide-react'
+import { useState, useEffect, useRef } from 'preact/compat'
interface PdsData {
- records?: ComAtprotoSyncListRepos.Output["repos"];
- cursor?: string;
- health?: PdsHealth;
- isLoading: boolean;
- error: Error | null;
- fetchMore: (cursor: string) => Promise;
+ records?: ComAtprotoSyncListRepos.Output['repos']
+ cursor?: string
+ health?: PdsHealth
+ isLoading: boolean
+ error: Error | null
+ fetchMore: (cursor: string) => Promise
}
interface PdsHealth {
- version: string;
+ version: string
}
function useRepoData(baseUrl: string): PdsData {
- const xrpc = useXrpc();
+ const xrpc = useXrpc()
const [state, setState] = useState({
isLoading: true,
error: null,
fetchMore: async () => {},
- });
+ })
- useDocumentTitle(baseUrl ? `${baseUrl} | atp.tools` : "atp.tools");
- const abortController = new AbortController();
+ useDocumentTitle(baseUrl ? `${baseUrl} | atp.tools` : 'atp.tools')
+ const abortController = new AbortController()
async function fetchRepoData(cursor?: string) {
try {
- setState((prev) => ({ ...prev, isLoading: true }));
+ setState((prev) => ({ ...prev, isLoading: true }))
// we dont use the main authenticated client here
- const rpc = new QtClient(new URL("https://" + baseUrl));
+ const rpc = new QtClient(new URL('https://' + baseUrl))
// get the PDS
const response = await rpc
.getXrpcClient()
- .get("com.atproto.sync.listRepos", {
+ .get('com.atproto.sync.listRepos', {
params: { limit: 1000, cursor },
signal: abortController.signal,
- });
+ })
const health = await rpc
.getXrpcClient()
- .request({ nsid: "_health", type: "get" });
+ .request({ nsid: '_health', type: 'get' })
setState((prev) => ({
...prev,
@@ -58,78 +58,78 @@ function useRepoData(baseUrl: string): PdsData {
health: health.data,
isLoading: false,
error: null,
- }));
+ }))
// todo: actual errors
} catch (err: any) {
- if (err.name === "AbortError") return;
+ if (err.name === 'AbortError') return
setState((prev) => ({
...prev,
isLoading: false,
- error: err instanceof Error ? err : new Error("An error occurred"),
- }));
+ error: err instanceof Error ? err : new Error('An error occurred'),
+ }))
}
}
useEffect(() => {
- fetchRepoData();
+ fetchRepoData()
return () => {
- abortController.abort();
- };
- }, [baseUrl, xrpc]);
+ abortController.abort()
+ }
+ }, [baseUrl, xrpc])
const fetchMore = async (cursor: string) => {
if (cursor && !state.isLoading) {
- await fetchRepoData(cursor);
+ await fetchRepoData(cursor)
}
- };
+ }
- return { ...state, fetchMore };
+ return { ...state, fetchMore }
}
-export const Route = createFileRoute("/pds/$url/")({
+export const Route = createLazyFileRoute('/pds/$url/')({
component: RouteComponent,
-});
+})
function RouteComponent() {
- const { url } = Route.useParams();
- const { cursor, records, fetchMore, isLoading, error } = useRepoData(url);
+ const { url } = Route.useParams()
+ const { cursor, records, fetchMore, isLoading, error } = useRepoData(url)
- useDocumentTitle(records ? `${url} (PDS) | atp.tools` : "atp.tools");
+ useDocumentTitle(records ? `${url} (PDS) | atp.tools` : 'atp.tools')
- const loaderRef = useRef(null);
+ const loaderRef = useRef(null)
useEffect(() => {
- if (!loaderRef.current) return;
+ if (!loaderRef.current) return
const observer = new IntersectionObserver(
(entries) => {
- const target = entries[0];
+ const target = entries[0]
if (target.isIntersecting && !isLoading && cursor) {
- fetchMore(cursor);
+ fetchMore(cursor)
}
},
- { threshold: 0.1, rootMargin: "50px" },
- );
+ { threshold: 0.1, rootMargin: '50px' },
+ )
- observer.observe(loaderRef.current);
- return () => observer.disconnect();
- }, [cursor, isLoading, fetchMore]);
+ observer.observe(loaderRef.current)
+ return () => observer.disconnect()
+ }, [cursor, isLoading, fetchMore])
if (error) {
- return ;
+ return
}
if ((isLoading && !cursor) || !records) {
- return ;
+ return
}
return (
- PDS: {url.includes("bsky.network") && "🍄"} {url}
+ PDS: {url.includes('bsky.network') && '🍄'} {url}
@@ -143,7 +143,7 @@ function RouteComponent() {
handle: c.did,
}}
>
- {c.did}{" "}
+ {c.did}{' '}
{!c.active && }
@@ -163,5 +163,5 @@ function RouteComponent() {
- );
+ )
}
diff --git a/src/routes/rnfgrertt/index.lazy.tsx b/src/routes/rnfgrertt/index.lazy.tsx
new file mode 100644
index 0000000..8fe1e1f
--- /dev/null
+++ b/src/routes/rnfgrertt/index.lazy.tsx
@@ -0,0 +1,9 @@
+import { createLazyFileRoute } from '@tanstack/react-router'
+
+export const Route = createLazyFileRoute('/rnfgrertt/')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/rnfrgrertt/"!
+}
diff --git a/src/routes/rnfgrertt/typing.lazy.tsx b/src/routes/rnfgrertt/typing.lazy.tsx
new file mode 100644
index 0000000..00634cf
--- /dev/null
+++ b/src/routes/rnfgrertt/typing.lazy.tsx
@@ -0,0 +1,418 @@
+import { createLazyFileRoute } from "@tanstack/react-router";
+import { useState, useEffect, useRef } from "preact/hooks";
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+} from "recharts";
+
+// Type definitions
+type TypingError = {
+ position: number;
+ timestamp: number;
+ expected: string;
+ actual: string;
+};
+
+type WPMDataPoint = {
+ time: number;
+ wpm: number;
+ errorsPerSecond: number;
+};
+
+type TypingStats = {
+ accuracy: string;
+ wpm: string;
+ time: string;
+ errorCount: number;
+};
+
+// Constants
+const SAMPLE_TEXT = "The quick brown fox jumps over the lazy dog";
+const UPDATE_INTERVAL = 500;
+const CHART_MARGIN = { top: 5, right: 30, left: 20, bottom: 5 };
+
+// Custom hooks
+const useWpmTracker = (
+ startTime: number | null,
+ isFinished: boolean,
+ userInput: string,
+) => {
+ const [wpmData, setWpmData] = useState([]);
+ const userInputRef = useRef(userInput);
+ const lastUpdateRef = useRef(0);
+ const prevErrorsRef = useRef(0);
+
+ useEffect(() => {
+ userInputRef.current = userInput;
+ if (userInputRef.current.length >= 5 && wpmData.length < 1) {
+ updateWPMData();
+ }
+ }, [userInput]);
+
+ const calculateMetrics = (currentTime: number) => {
+ if (startTime === null)
+ return { timeElapsed: 0, correctChars: 0, currentErrors: 0 };
+ const timeElapsed = (currentTime - startTime) / 1000;
+ const currentInput = userInputRef.current;
+
+ // Calculate correct characters and current errors
+ let correctChars = 0;
+ let currentErrors = 0;
+
+ for (let i = 0; i < currentInput.length; i++) {
+ if (currentInput[i] === SAMPLE_TEXT[i]) {
+ correctChars++;
+ } else {
+ currentErrors++;
+ }
+ }
+
+ return { timeElapsed, correctChars, currentErrors };
+ };
+
+ const updateWPMData = () => {
+ const now = Date.now();
+ const { timeElapsed, correctChars, currentErrors } = calculateMetrics(now);
+
+ // Calculate errors per second
+ const timeDiff = (now - (lastUpdateRef.current || now)) / 1000;
+ const errorDelta = currentErrors - prevErrorsRef.current;
+ const errorsPerSecond = timeDiff > 0 ? errorDelta / timeDiff : 0;
+
+ setWpmData((prev) => [
+ ...prev,
+ {
+ time: Number(timeElapsed.toFixed(1)),
+ wpm: Math.round(correctChars / 5 / (timeElapsed / 60)),
+ errorsPerSecond: Number(errorsPerSecond.toFixed(1)),
+ },
+ ]);
+
+ lastUpdateRef.current = now;
+ prevErrorsRef.current = currentErrors;
+ };
+
+ useEffect(() => {
+ if (!startTime || isFinished) return;
+
+ // Subsequent updates at interval
+ const intervalId = setInterval(updateWPMData, UPDATE_INTERVAL);
+
+ return () => clearInterval(intervalId);
+ }, [startTime, isFinished]);
+
+ const resetWpmData = () => setWpmData([]);
+ return { wpmData, resetWpmData };
+};
+
+const useErrorTracker = (errors: TypingError[]) => {
+ const [errorDistribution, setErrorDistribution] = useState<
+ { position: number; count: number }[]
+ >([]);
+
+ useEffect(() => {
+ const distribution = new Array(SAMPLE_TEXT.length).fill(0);
+ errors.forEach(({ position }) => distribution[position]++);
+ setErrorDistribution(
+ distribution
+ .map((count, position) => ({ position, count }))
+ .filter((d) => d.count > 0),
+ );
+ }, [errors]);
+
+ return errorDistribution;
+};
+
+const useTypingTest = () => {
+ const [state, setState] = useState({
+ userInput: "",
+ isFinished: false,
+ startTime: null as number | null,
+ endTime: null as number | null,
+ errors: [] as TypingError[],
+ });
+
+ const handleInput = (input: string) => {
+ if (state.isFinished) return;
+
+ const newInput = input.slice(0, SAMPLE_TEXT.length);
+ const newErrors = [...state.errors];
+
+ // Track new errors
+ if (newInput.length > state.userInput.length) {
+ const newCharIndex = newInput.length - 1;
+ if (newInput[newCharIndex] !== SAMPLE_TEXT[newCharIndex]) {
+ newErrors.push({
+ position: newCharIndex,
+ timestamp: Date.now(),
+ expected: SAMPLE_TEXT[newCharIndex],
+ actual: newInput[newCharIndex],
+ });
+ }
+ }
+
+ setState((prev) => ({
+ ...prev,
+ userInput: newInput,
+ errors: newErrors,
+ startTime: prev.startTime || (newInput.length > 0 ? Date.now() : null),
+ isFinished: newInput.length === SAMPLE_TEXT.length,
+ endTime:
+ newInput.length === SAMPLE_TEXT.length ? Date.now() : prev.endTime,
+ }));
+ };
+
+ const resetTest = () => {
+ setState({
+ userInput: "",
+ isFinished: false,
+ startTime: null,
+ endTime: null,
+ errors: [],
+ });
+ };
+
+ return { ...state, handleInput, resetTest };
+};
+
+// UI Components
+const ResultsView = ({
+ stats,
+ wpmData,
+ resetTest,
+}: {
+ stats: TypingStats;
+ wpmData: WPMDataPoint[];
+ resetTest: () => void;
+}) => (
+
+);
+
+const StatsGrid = ({ stats }: { stats: TypingStats }) => (
+
+ {Object.entries(stats).map(([key, value]) => (
+
+ ))}
+
+);
+
+const StatBox = ({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | number;
+}) => (
+
+
{label}
+
{value}
+
{getUnit(label)}
+
+);
+
+const PerformanceChart = ({ wpmData }: { wpmData: WPMDataPoint[] }) => (
+
+
Performance Over Time
+
+
+
+
+ d.errorsPerSecond), 1)]}
+ />
+ } />
+
+
+
+
+
+);
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (active && payload?.length) {
+ const data = payload[0].payload;
+ return (
+
+
Time: {data.time}s
+
WPM: {data.wpm}
+
+ Errors/sec: {data.errorsPerSecond}
+
+
+ );
+ }
+ return null;
+};
+
+const TypingArea = ({
+ userInput,
+ handleInput,
+}: {
+ userInput: string;
+ handleInput: (e: React.ChangeEvent) => void;
+}) => (
+
+
+
+
+);
+
+const TextDisplay = ({ userInput }: { userInput: string }) => (
+
+ {SAMPLE_TEXT.split("").map((char, i) => {
+ const status =
+ i < userInput.length
+ ? userInput[i] === char
+ ? "text-green-500"
+ : "text-red-500 underline"
+ : "";
+ return (
+
+ {char}
+
+ );
+ })}
+
+);
+
+// Helper components
+
+const CustomErrorDot = (props: any) =>
+ props.payload.errors >= 1 ? (
+
+ ) : null;
+
+const ResetButton = ({ onClick }: { onClick: () => void }) => (
+
+);
+
+// Helper functions
+const getUnit = (label: string) => {
+ switch (label) {
+ case "accuracy":
+ return "%";
+ case "wpm":
+ return "WPM";
+ case "time":
+ return "seconds";
+ case "errorCount":
+ return "total";
+ default:
+ return "";
+ }
+};
+
+const calculateStats = (
+ userInput: string,
+ errors: TypingError[],
+ startTime: number,
+ endTime: number,
+): TypingStats => {
+ const timeElapsed = (endTime - startTime) / 1000;
+ const correctChars = userInput
+ .split("")
+ .filter((char, i) => char === SAMPLE_TEXT[i]).length;
+
+ return {
+ accuracy: ((correctChars / SAMPLE_TEXT.length) * 100).toFixed(1),
+ wpm: (correctChars / 5 / (timeElapsed / 60)).toFixed(1),
+ time: timeElapsed.toFixed(1),
+ errorCount: errors.length,
+ };
+};
+
+// Main component
+export const Route = createLazyFileRoute("/rnfgrertt/typing")({
+ component: () => {
+ const {
+ userInput,
+ isFinished,
+ startTime,
+ endTime,
+ errors,
+ handleInput,
+ resetTest,
+ } = useTypingTest();
+ const { wpmData, resetWpmData } = useWpmTracker(
+ startTime,
+ isFinished,
+ userInput,
+ );
+ //const errorDistribution = useErrorTracker(errors);
+
+ const resetAll = () => {
+ resetTest();
+ resetWpmData();
+ };
+
+ const stats =
+ startTime && endTime
+ ? calculateStats(userInput, errors, startTime, endTime)
+ : ({} as TypingStats);
+
+ return (
+
+ {isFinished ? (
+
+ ) : (
+
+ handleInput((e.target as HTMLTextAreaElement).value)
+ }
+ />
+ )}
+
+ );
+ },
+});
diff --git a/tailwind.config.js b/tailwind.config.js
index d4c4685..6476f4d 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,67 +1,72 @@
+import { fontFamily } from "tailwindcss/defaultTheme";
+
/** @type {import('tailwindcss').Config} */
-module.exports = {
- darkMode: ["class"],
- content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
- theme: {
- extend: {
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)'
- },
- colors: {
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))'
- },
- popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))'
- },
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))'
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))'
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))'
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))'
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))'
- },
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- chart: {
- '1': 'hsl(var(--chart-1))',
- '2': 'hsl(var(--chart-2))',
- '3': 'hsl(var(--chart-3))',
- '4': 'hsl(var(--chart-4))',
- '5': 'hsl(var(--chart-5))'
- },
- sidebar: {
- DEFAULT: 'hsl(var(--sidebar-background))',
- foreground: 'hsl(var(--sidebar-foreground))',
- primary: 'hsl(var(--sidebar-primary))',
- 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
- accent: 'hsl(var(--sidebar-accent))',
- 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
- border: 'hsl(var(--sidebar-border))',
- ring: 'hsl(var(--sidebar-ring))'
- }
- }
- }
+export const darkMode = ["class"];
+export const content = ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"];
+export const theme = {
+ extend: {
+ fontFamily: {
+ sans: ["var(--font-sans)", ...fontFamily.sans],
+ serif: ["var(--font-serif)", ...fontFamily.serif],
+ mono: ["var(--font-mono)", ...fontFamily.mono],
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ chart: {
+ 1: "hsl(var(--chart-1))",
+ 2: "hsl(var(--chart-2))",
+ 3: "hsl(var(--chart-3))",
+ 4: "hsl(var(--chart-4))",
+ 5: "hsl(var(--chart-5))",
+ },
+ sidebar: {
+ DEFAULT: "hsl(var(--sidebar-background))",
+ foreground: "hsl(var(--sidebar-foreground))",
+ primary: "hsl(var(--sidebar-primary))",
+ "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
+ accent: "hsl(var(--sidebar-accent))",
+ "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
+ border: "hsl(var(--sidebar-border))",
+ ring: "hsl(var(--sidebar-ring))",
+ },
+ },
},
- plugins: [require("tailwindcss-animate")],
};
+export const plugins = [require("tailwindcss-animate")];