diff --git a/package.json b/package.json index 2807bfd1..a58efbb1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@react-three/fiber": "^9.6.1", "@vercel/analytics": "^2.0.1", + "cmdk": "^1.1.1", + "fuse.js": "^7.4.2", "ioredis": "^5.11.1", "js-yaml": "^4.1.1", "lucide-react": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec4c0fa2..0d7caede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + fuse.js: + specifier: ^7.4.2 + version: 7.4.2 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -653,6 +659,177 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.17': + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + 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-dismissable-layer@1.1.13': + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + 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.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.10': + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + 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.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.12': + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} + 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.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + 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-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + 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-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-three/fiber@9.6.1': resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} peerDependencies: @@ -1077,6 +1254,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1215,6 +1396,12 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1318,6 +1505,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1614,6 +1804,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.4.2: + resolution: {integrity: sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==} + engines: {node: '>=10'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -1630,6 +1824,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2266,6 +2464,36 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + 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-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + 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-use-measure@2.1.7: resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} peerDependencies: @@ -2576,6 +2804,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + 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 + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + 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 + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3114,6 +3362,148 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0)': dependencies: '@babel/runtime': 7.29.7 @@ -3473,6 +3863,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3642,6 +4036,18 @@ snapshots: cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3725,6 +4131,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4216,6 +4624,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.4.2: {} + generator-function@2.0.1: {} generic-pool@3.9.0: {} @@ -4235,6 +4645,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -4828,6 +5240,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -5266,6 +5705,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/app/globals.css b/src/app/globals.css index b0c43984..a97dd466 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -219,7 +219,13 @@ table { /* Editorial section labels - sans-serif with wide tracking + uppercase * for a Bloomberg-style tech-editorial feel, instead of the developer-tool - * vibe that monospace gives off when used on body / UI labels. */ + * vibe that monospace gives off when used on body / UI labels. + * + * `.label-mono` remains for backward compat. The three sized variants below + * harmonise the three near-identical Tailwind class strings repeated ~133 + * times across `src/**/*.tsx` (text-[10px]/[11px]/xs + 0.16em/0.18em + the + * matching ink ramp colour). Centralising them here means a copy-edit to + * the tracking or color ramp lands in one place instead of every call site. */ .label-mono { font-family: var(--font-sans); font-size: 10px; @@ -228,6 +234,28 @@ table { letter-spacing: 0.18em; } +@layer components { + .label-mono-xs { + @apply text-[10px] uppercase tracking-[0.16em] text-ink-faint; + } + .label-mono-sm { + @apply text-[11px] uppercase tracking-[0.18em] text-ink-muted font-medium font-sans; + } + .label-mono-lg { + @apply text-xs uppercase tracking-[0.18em] text-ink-muted font-medium; + } + + /* Page-level H1 hero treatment. Matches the dominant inline class + * string shared by index / answers / compare / alternatives / + * chains landing pages (display + 3xl-4xl ramp + ink + tight + * leading). Centralised so a copy-edit to the page-title scale + * lands in one place instead of every list landing route. */ + .h1-hero { + font-family: var(--font-display); + @apply text-3xl sm:text-4xl text-ink leading-[1.05]; + } +} + /* Chart pop animations */ @keyframes chart-pop-rise { 0% { opacity: 0; transform: translate(0, calc(-50% + 4px)) scale(0.92); } @@ -302,6 +330,60 @@ textarea:focus-visible { border-radius: 4px; } +/* Search dialog open/close animations. tailwindcss-animate isn't + installed; these handcrafted keyframes give us the fade + slight + slide-down on open and the inverse on close. Linear-style: snappy, + no scale bounce. */ +@keyframes ocb-search-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes ocb-search-overlay-out { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes ocb-search-card-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes ocb-search-card-out { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-4px); } +} +.ocb-search-overlay-in { + animation: ocb-search-overlay-in 150ms ease-out; +} +.ocb-search-overlay-out { + animation: ocb-search-overlay-out 100ms ease-in forwards; +} +.ocb-search-card-in { + animation: ocb-search-card-in 150ms cubic-bezier(0.16, 0.84, 0.32, 1); +} +.ocb-search-card-out { + animation: ocb-search-card-out 100ms ease-in forwards; +} +@media (prefers-reduced-motion: reduce) { + .ocb-search-overlay-in, + .ocb-search-overlay-out, + .ocb-search-card-in, + .ocb-search-card-out { + animation: none !important; + } +} + +/* cmdk command palette input — opts out of the universal focus ring. + The dialog already provides visual focus context (modal backdrop + + centered card); a hard accent outline on the input itself reads as + a validation error rather than a focus state, which is the look the + designer flagged as "pas pro". The input still receives focus and + keyboard nav, just without the harsh outline. */ +[cmdk-input]:focus, +[cmdk-input]:focus-visible { + outline: none !important; + outline-offset: 0; + box-shadow: none !important; +} + /* Skip-link target. Hidden until keyboard focus brings it onto the page, then snaps to the top-left so a screen-reader / keyboard user can bypass the nav. */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4fb27720..28108fb1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; import { SiteHeader } from "@/components/site-header"; import { SiteFooter } from "@/components/site-footer"; +import { SearchProvider } from "@/components/search/search-provider"; +import { buildSearchIndex } from "@/lib/search/buildIndex"; import { SITE } from "@/data/site"; import { safeJsonLd } from "@/lib/jsonld"; @@ -54,7 +56,7 @@ const IS_STAGING = !!process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production"; export const metadata: Metadata = { - metadataBase: new URL("https://openchainbench.com"), + metadataBase: new URL(SITE.url), title: { default: "OpenChainBench. Open benchmarks for crypto infrastructure", template: "%s · OpenChainBench", @@ -69,7 +71,7 @@ export const metadata: Metadata = { description: "Live benchmarks for crypto infrastructure: RPC latency, bridge fees, L2 finality and price feed accuracy.", type: "website", - url: "https://openchainbench.com", + url: SITE.url, siteName: "OpenChainBench", }, twitter: { @@ -140,9 +142,12 @@ const ORG_JSONLD = { ], }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + // Index is built once per server runtime (memoised via React `cache`), + // shipped as JSON to the client provider. ~400 docs, ~25-40 KB raw. + const searchItems = await buildSearchIndex(); return ( Skip to main content - -
{children}
- + + +
{children}
+ +
{process.env.VERCEL_ENV === "production" && } diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx new file mode 100644 index 00000000..d64fa850 --- /dev/null +++ b/src/components/search/search-dialog.tsx @@ -0,0 +1,624 @@ +"use client"; + +import { Command } from "cmdk"; +import Fuse from "fuse.js"; +import { + ArrowRight, + Building2, + FileText, + GitCompareArrows, + HelpCircle, + Layers, + Link2, + Search, + Trophy, + X, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSearch } from "@/components/search/search-provider"; +import { useRecentSearches, type RecentEntry } from "@/components/search/use-recent-searches"; +import { ProviderLogo } from "@/components/provider-logo"; +import type { SearchItem, SearchKind } from "@/lib/search/types"; + +const KIND_ORDER: SearchKind[] = [ + "Benchmark", + "Product", + "Compare", + "Alternative", + "Answer", + "Chain", + "Page", +]; + +const KIND_LABEL: Record = { + Benchmark: "Benchmarks", + Product: "Products", + Compare: "Compare", + Alternative: "Alternatives", + Answer: "Answers", + Chain: "Chains", + Page: "Pages", +}; + +const KIND_SINGULAR: Record = { + Benchmark: "Bench", + Product: "Product", + Compare: "vs", + Alternative: "Alt", + Answer: "Answer", + Chain: "Chain", + Page: "Page", +}; + +const KIND_ICON: Record = { + Benchmark: Trophy, + Product: Building2, + Compare: GitCompareArrows, + Alternative: Layers, + Answer: HelpCircle, + Chain: Link2, + Page: FileText, +}; + +/** + * Hand-picked editorial leaders. Shown as horizontal "Live leaders" cards + * when the query is empty. Live values (#1 provider + p50) come from + * `/api/citable`, which is already edge-cached for 300s. All slugs must + * appear in the citable response (editorialStatus === "live" + leader() + * non-null) — verified against prod before shipping. + */ +const FEATURED_BENCH_SLUGS = [ + "pm-data-freshness", + "aggregator-head-lag", + "l1-finality", + "rpc-capabilities", + "perp-fees", + "bridge-quote-latency", +]; + +const TRENDING_BENCH_SLUGS = [ + "stablecoin-peg-usdt-anchored", + "metadata-coverage", + "validator-yield", + "network-fees", + "perp-funding", + "solana-tx-landing", +]; + +type CitableLeader = { + slug: string; + title: string; + category: string; + value: number | null; + unit: string; + leader: { name: string; slug: string; value: number } | null; +}; + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function SectionHeader({ label, action }: { label: string; action?: React.ReactNode }) { + return ( +
+ + {label} + + {action} +
+ ); +} + +function KindIcon({ kind, size = 14 }: { kind: SearchKind; size?: number }) { + const Icon = KIND_ICON[kind]; + return ; +} + +function fmtUnit(value: number | null | undefined, unit: string): string { + if (value == null) return "—"; + if (unit === "ms") { + if (value < 1000) return `${Math.round(value)} ms`; + return `${(value / 1000).toFixed(2)} s`; + } + if (unit === "s" || unit === "sec") return `${value.toFixed(2)} s`; + if (unit === "pct") return `${value.toFixed(1)}%`; + if (unit === "bps" || unit === "bp") return `${value.toFixed(1)} bp`; + if (unit === "usd") { + if (value > 1e9) return `$${(value / 1e9).toFixed(1)}B`; + if (value > 1e6) return `$${(value / 1e6).toFixed(1)}M`; + if (value > 1e3) return `$${(value / 1e3).toFixed(1)}k`; + return `$${value.toFixed(0)}`; + } + if (unit === "count") return value.toLocaleString(); + return String(value); +} + +export default function SearchDialog() { + const { items, close: onClose } = useSearch(); + const router = useRouter(); + const [query, setQuery] = useState(""); + const [isClosing, setIsClosing] = useState(false); + const inputRef = useRef(null); + const { recent, push: pushRecent, remove: removeRecent, clear: clearRecent } = + useRecentSearches(); + + // Featured / trending live data. One fetch on mount, cached by browser + // since /api/citable ships s-maxage=300. + const [citable, setCitable] = useState | null>(null); + useEffect(() => { + let cancelled = false; + fetch("/api/citable") + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (cancelled || !j?.benchmarks) return; + const map = new Map(); + for (const b of j.benchmarks as CitableLeader[]) map.set(b.slug, b); + setCitable(map); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + // One Fuse instance per dialog mount. The full corpus is ~400 docs + // so build cost is sub-millisecond, no need to memoise across mounts. + const fuse = useMemo( + () => + new Fuse(items, { + keys: [ + { name: "title", weight: 0.6 }, + { name: "tags", weight: 0.3 }, + { name: "description", weight: 0.1 }, + ], + threshold: 0.35, + minMatchCharLength: 2, + ignoreLocation: true, + }), + [items], + ); + + // Body scroll lock + ESC handler with deferred close so the exit + // animation has time to play. + const close = useMemo( + () => () => { + setIsClosing(true); + window.setTimeout(onClose, 120); + }, + [onClose], + ); + + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + window.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = prev; + window.removeEventListener("keydown", onKey); + }; + }, [close]); + + useEffect(() => { + const t = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(t); + }, []); + + const benchItemBySlug = useMemo(() => { + const map = new Map(); + for (const it of items) { + if (it.kind === "Benchmark") { + const slug = it.id.replace(/^bench:/, ""); + map.set(slug, it); + } + } + return map; + }, [items]); + + const featured = useMemo(() => { + return FEATURED_BENCH_SLUGS + .map((slug) => { + const item = benchItemBySlug.get(slug); + if (!item) return null; + const live = citable?.get(slug); + return { item, live: live ?? null }; + }) + .filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x)); + }, [benchItemBySlug, citable]); + + const trending = useMemo(() => { + return TRENDING_BENCH_SLUGS + .map((slug) => { + const item = benchItemBySlug.get(slug); + if (!item) return null; + const live = citable?.get(slug); + return { item, live: live ?? null }; + }) + .filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x)); + }, [benchItemBySlug, citable]); + + const results = useMemo(() => { + const q = query.trim(); + if (!q) return []; + return fuse.search(q, { limit: 16 }).map((r) => r.item); + }, [query, fuse]); + + const grouped = useMemo(() => { + const map = new Map(); + for (const it of results) { + const list = map.get(it.kind) ?? []; + list.push(it); + map.set(it.kind, list); + } + return KIND_ORDER + .map((kind) => ({ kind, list: map.get(kind) ?? [] })) + .filter((g) => g.list.length > 0); + }, [results]); + + function go(url: string, entry?: RecentEntry) { + if (entry) pushRecent(entry); + close(); + router.push(url); + } + + function entryFromItem(it: SearchItem): RecentEntry { + const slug = + it.kind === "Benchmark" + ? it.id.replace(/^bench:/, "") + : it.kind === "Product" + ? it.id.replace(/^product:/, "") + : it.kind === "Chain" + ? it.id.replace(/^chain:/, "") + : undefined; + return { id: it.id, title: it.title, url: it.url, kind: it.kind, slug }; + } + + const trimmed = query.trim(); + const isSearching = trimmed.length > 0; + const showEmpty = isSearching && results.length === 0; + + return ( +
+
e.stopPropagation()} + > + + {/* Input row */} +
+ + + + +
+ + + {/* IDLE STATE — Recent + Featured + Trending */} + {!isSearching && ( +
+ {recent.length > 0 && ( +
+ + Clear + + } + /> +
+
+ {recent.map((r) => { + const Icon = KIND_ICON[r.kind]; + return ( +
+ + +
+ ); + })} +
+
+
+ )} + + {featured.length > 0 && ( +
+ + + live + + ) + } + /> +
+
+ {featured.map(({ item, live }) => { + const category = item.tags?.[0] ?? "Benchmark"; + const isLoading = citable === null; + return ( + + ); + })} +
+
+
+ )} + + {trending.length > 0 && ( +
+ + + {trending.map(({ item, live }) => { + const category = item.tags?.[0] ?? "Benchmark"; + const isLoading = citable === null; + return ( + go(item.url, entryFromItem(item))} + className="group flex items-center gap-3 rounded-md px-2.5 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft transition-colors" + > + {live?.leader ? ( + + ) : ( +
+ +
+ )} +
+
+ {item.title} +
+
+ {live?.leader ? ( + <> + #1 {live.leader.name} ·{" "} + + {fmtUnit(live.value, live.unit)} + + + ) : isLoading ? ( + + ) : ( + category + )} +
+
+ +
+ ); + })} +
+
+ )} +
+ )} + + {/* SEARCH RESULTS STATE */} + {isSearching && ( + <> + {showEmpty && ( + + +

+ No results for “{trimmed}”. +

+

+ Try a bench name, provider, or chain. +

+
+ )} + + {grouped.map(({ kind, list }) => ( + + {list.map((it) => { + const entry = entryFromItem(it); + const benchLive = + it.kind === "Benchmark" && entry.slug + ? citable?.get(entry.slug) + : null; + const logoSlug = + it.kind === "Benchmark" + ? benchLive?.leader?.slug + : entry.slug; + const logoName = + it.kind === "Benchmark" + ? benchLive?.leader?.name ?? it.title + : it.title; + return ( + go(it.url, entry)} + className="group flex items-center gap-3 rounded-md px-2.5 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft transition-colors" + > + {logoSlug ? ( + + ) : ( +
+ +
+ )} +
+
+ + {it.title} + + + {KIND_SINGULAR[it.kind]} + +
+ {it.description && ( +
+ {it.description} +
+ )} +
+ +
+ ); + })} +
+ ))} + + )} +
+ + {/* Footer */} +
+
+ + + + navigate + + + + open + +
+ + esc + close + +
+
+
+
+ ); +} diff --git a/src/components/search/search-provider.tsx b/src/components/search/search-provider.tsx new file mode 100644 index 00000000..4a76df6f --- /dev/null +++ b/src/components/search/search-provider.tsx @@ -0,0 +1,82 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { SearchItem } from "@/lib/search/types"; + +type Ctx = { + open: () => void; + close: () => void; + isOpen: boolean; + items: SearchItem[]; +}; + +const SearchCtx = createContext(null); + +/** + * Lazy-load the dialog (and with it, cmdk + Fuse.js) on the first open. + * Keeps the dialog out of the initial JS payload so the header trigger + * is cheap to ship sitewide. + */ +const SearchDialog = dynamic(() => import("@/components/search/search-dialog"), { + ssr: false, +}); + +type ProviderProps = { + items: SearchItem[]; + children: React.ReactNode; +}; + +export function SearchProvider({ items, children }: ProviderProps) { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + + // Global keyboard shortcuts. Cmd+K (mac) / Ctrl+K (win/linux) toggle + // the dialog. `/` opens it the way GitHub does, but only when the + // active element is not already an input, so typing `/` in a search + // box on a bench page doesn't fire twice. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setIsOpen((v) => !v); + return; + } + if (e.key === "/" && !isOpen) { + const t = e.target as HTMLElement | null; + const tag = t?.tagName; + const editable = t?.isContentEditable; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable) return; + e.preventDefault(); + setIsOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen]); + + const value = useMemo( + () => ({ open, close, isOpen, items }), + [open, close, isOpen, items], + ); + + return ( + + {children} + {isOpen && } + + ); +} + +export function useSearch(): Ctx { + const ctx = useContext(SearchCtx); + if (!ctx) { + // Soft fallback: when something tries to open the dialog outside + // the provider (shouldn't happen, but cheap to guard) we make the + // trigger inert rather than crash the page. + return { open: () => {}, close: () => {}, isOpen: false, items: [] }; + } + return ctx; +} diff --git a/src/components/search/search-trigger.tsx b/src/components/search/search-trigger.tsx new file mode 100644 index 00000000..c577f526 --- /dev/null +++ b/src/components/search/search-trigger.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Search } from "lucide-react"; +import { useSearch } from "@/components/search/search-provider"; + +type Props = { + /** + * - `desktop`: wide input-style pill shown in the desktop nav. Reads + * visually as a real search field (light surface, rounded, full + * width within its container) so users discover the feature without + * needing the keyboard shortcut. + * - `mobile`: 44×44 icon-only square sized to the same min-tap target + * as the hamburger. Hidden at md+. + */ + variant: "desktop" | "mobile"; +}; + +export function SearchTrigger({ variant }: Props) { + const { open } = useSearch(); + + if (variant === "mobile") { + return ( + + ); + } + + // Input-look trigger. Soft surface + rule border so the affordance + // reads as a text field even though it actually opens the cmdk modal. + // No platform-specific shortcut hint: ⌘K is wrong on Windows/Linux, + // Ctrl K is wrong on Mac, and a dynamic swap creates hydration + // flicker for two-character gain. Shortcut still works, just not + // advertised in the chrome. + return ( + + ); +} diff --git a/src/components/search/use-recent-searches.ts b/src/components/search/use-recent-searches.ts new file mode 100644 index 00000000..ac0956c5 --- /dev/null +++ b/src/components/search/use-recent-searches.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useSyncExternalStore, useCallback } from "react"; +import type { SearchKind } from "@/lib/search/types"; + +const KEY = "ocb:search:recent:v1"; +const MAX_RECENT = 7; +const CHANGE_EVENT = "ocb:search:recent:changed"; + +export type RecentEntry = { + id: string; + title: string; + url: string; + kind: SearchKind; + /** Optional slug for logo resolution (provider/chain only). */ + slug?: string; +}; + +function readStorage(): RecentEntry[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.slice(0, MAX_RECENT) as RecentEntry[]; + } catch { + return []; + } +} + +function writeStorage(entries: RecentEntry[]) { + try { + window.localStorage.setItem(KEY, JSON.stringify(entries)); + // Same-tab notify. The native `storage` event only fires on OTHER + // tabs, so without this the dialog wouldn't see its own writes + // unless remounted. + window.dispatchEvent(new Event(CHANGE_EVENT)); + } catch { + // private-mode Safari, sandboxed iframes, etc. — fail silently. + } +} + +function subscribe(cb: () => void) { + if (typeof window === "undefined") return () => {}; + const handler = () => cb(); + window.addEventListener("storage", handler); + window.addEventListener(CHANGE_EVENT, handler); + return () => { + window.removeEventListener("storage", handler); + window.removeEventListener(CHANGE_EVENT, handler); + }; +} + +// useSyncExternalStore expects snapshot stability between renders that +// share the same underlying data, otherwise React falls into a render +// loop. Stable cache keyed by the raw JSON string so a fresh read of +// the same payload returns the same array reference. +let cachedJson = ""; +let cachedSnapshot: RecentEntry[] = []; +function getSnapshot(): RecentEntry[] { + const raw = (typeof window !== "undefined" && window.localStorage.getItem(KEY)) || "[]"; + if (raw === cachedJson) return cachedSnapshot; + cachedJson = raw; + cachedSnapshot = readStorage(); + return cachedSnapshot; +} + +const SERVER_SNAPSHOT: RecentEntry[] = []; +const getServerSnapshot = () => SERVER_SNAPSHOT; + +export function useRecentSearches() { + const recent = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const push = useCallback((entry: RecentEntry) => { + const current = readStorage(); + const next = [entry, ...current.filter((e) => e.id !== entry.id)].slice(0, MAX_RECENT); + writeStorage(next); + }, []); + + const remove = useCallback((id: string) => { + const current = readStorage(); + writeStorage(current.filter((e) => e.id !== id)); + }, []); + + const clear = useCallback(() => { + writeStorage([]); + }, []); + + return { recent, push, remove, clear }; +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index f238c6c1..0c62dc1f 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -2,7 +2,9 @@ import { Menu, X } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; +import { SearchTrigger } from "@/components/search/search-trigger"; import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -14,16 +16,35 @@ function GithubIcon({ size = 15 }: { size?: number }) { ); } -const NAV = [ - { href: "/benchmarks", label: "Benchmarks" }, - { href: "/products", label: "Products" }, - { href: "/methodology", label: "Methodology" }, - { href: "/about", label: "About" }, - { href: "/contribute", label: "Contribute" }, +type NavItem = { href: string; label: string; match: (p: string) => boolean }; + +// Active-state predicates. Bench/product detail pages share the same +// tab as the index, so `/benchmarks/aggregator-head-lag` highlights +// the "Benchmarks" tab. `/` matches exact only — without that, every +// route would inherit a "Home" highlight. +const NAV: NavItem[] = [ + { + href: "/benchmarks", + label: "Benchmarks", + match: (p) => p === "/benchmarks" || p.startsWith("/benchmarks/"), + }, + { + href: "/products", + label: "Products", + match: (p) => p === "/products" || p.startsWith("/products/"), + }, + { + href: "/methodology", + label: "Methodology", + match: (p) => p === "/methodology", + }, + { href: "/about", label: "About", match: (p) => p === "/about" }, + { href: "/contribute", label: "Contribute", match: (p) => p === "/contribute" }, ]; export function SiteHeader() { const [open, setOpen] = useState(false); + const pathname = usePathname() ?? "/"; // Close the mobile menu when the viewport crosses md so the dropdown // doesn't stick around as the desktop nav reappears. @@ -46,11 +67,11 @@ export function SiteHeader() { // not visible there in practice. Override lives in globals.css // (`html.ios-webview .site-header-root` rule) so the WebView-only // selector stays out of the Tailwind class soup here. - className="site-header-root sticky top-0 z-50 flex flex-col font-sans bg-surface" + className="site-header-root sticky top-0 z-50 flex flex-col font-sans bg-surface/95 backdrop-blur supports-[backdrop-filter]:bg-surface/80" > -
-
-
+
+
+
- +
- +
+ + +
{open && ( @@ -103,17 +153,24 @@ export function SiteHeader() { className="md:hidden absolute left-0 right-0 top-full border-b border-rule bg-surface shadow-lg" >