From 13bca5d95de10c76e95b05beddea20de8cabda61 Mon Sep 17 00:00:00 2001 From: Nolawz Date: Tue, 3 Mar 2026 11:52:14 +0530 Subject: Added search functionality --- bun.lock | 51 +- package.json | 2 + src/components/Head.astro | 6 + src/components/Header.astro | 2 + src/components/react/ErrorBoundary.tsx | 63 +++ src/components/react/SearchButton.tsx | 51 ++ src/components/react/SearchDialog.tsx | 979 +++++++++++++++++++++++++++++++++ src/components/ui/dialog.tsx | 120 ++++ src/pages/api/search-index.json.ts | 74 +++ 9 files changed, 1339 insertions(+), 9 deletions(-) create mode 100644 src/components/react/ErrorBoundary.tsx create mode 100644 src/components/react/SearchButton.tsx create mode 100644 src/components/react/SearchDialog.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/pages/api/search-index.json.ts diff --git a/bun.lock b/bun.lock index fea5dad..dcedc9f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@expressive-code/plugin-line-numbers": "^0.40.2", "@iconify-json/lucide": "^1.2.26", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", "@shikijs/rehype": "^3.4.0", "@tailwindcss/vite": "^4.0.7", "@types/react": "19.0.0", @@ -23,6 +24,7 @@ "astro-icon": "^1.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flexsearch": "^0.8.212", "lucide-react": "^0.469.0", "radix-ui": "^1.3.4", "react": "19.0.0", @@ -32,6 +34,7 @@ "rehype-katex": "^7.0.1", "remark-emoji": "^5.0.1", "remark-math": "^6.0.0", + "sharp": "^0.34.5", "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.7", "typescript": "^5.8.3", @@ -309,15 +312,15 @@ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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" } }, "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" } }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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" } }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "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" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], @@ -781,6 +784,8 @@ "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + "flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="], + "fontace": ["fontace@0.4.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-moThBCItUe2bjZip5PF/iZClpKHGLwMvR79Kp8XpGRBrvoRSnySN4VcILdv3/MJzbhvUA5WeiUXF5o538m5fvg=="], "fontkitten": ["fontkitten@1.0.2", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q=="], @@ -1471,34 +1476,38 @@ "@radix-ui/react-alert-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" } }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "@radix-ui/react-collapsible/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-collapsible/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@radix-ui/react-context-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], - "@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], - - "@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], - "@radix-ui/react-dropdown-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-form/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-hover-card/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-hover-card/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-hover-card/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@radix-ui/react-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@radix-ui/react-menubar/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-navigation-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@radix-ui/react-one-time-password-field/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -1507,6 +1516,10 @@ "@radix-ui/react-popover/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-popover/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@radix-ui/react-radio-group/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -1521,6 +1534,10 @@ "@radix-ui/react-select/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-slider/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-switch/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -1531,6 +1548,8 @@ "@radix-ui/react-toast/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-toast/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-toast/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@radix-ui/react-toggle/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -1541,6 +1560,8 @@ "@radix-ui/react-tooltip/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -1577,6 +1598,12 @@ "radix-ui/@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "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" } }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="], + "radix-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" } }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + + "radix-ui/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "radix-ui/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "radix-ui/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], "sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], @@ -1623,6 +1650,12 @@ "@iconify/tools/svgo/css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], "boxen/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], diff --git a/package.json b/package.json index 608b247..7cdae56 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@expressive-code/plugin-line-numbers": "^0.40.2", "@iconify-json/lucide": "^1.2.26", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", "@shikijs/rehype": "^3.4.0", "@tailwindcss/vite": "^4.0.7", "@types/react": "19.0.0", @@ -30,6 +31,7 @@ "astro-icon": "^1.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flexsearch": "^0.8.212", "lucide-react": "^0.469.0", "radix-ui": "^1.3.4", "react": "19.0.0", diff --git a/src/components/Head.astro b/src/components/Head.astro index 8ce42cb..8474374 100644 --- a/src/components/Head.astro +++ b/src/components/Head.astro @@ -43,6 +43,12 @@ import Favicons from './Favicons.astro' /> + diff --git a/src/components/Header.astro b/src/components/Header.astro index c8b06d1..2402c2a 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,6 +1,7 @@ --- import Link from '@/components/Link.astro' import ThemeToggle from '@/components/ThemeToggle.astro' +import SearchButton from '@/components/react/SearchButton' import { NAV_LINKS, SITE } from '@/consts' import { Image } from 'astro:assets' import logo from '../../public/static/logo.svg' @@ -30,6 +31,7 @@ import logo from '../../public/static/logo.svg' )) } + diff --git a/src/components/react/ErrorBoundary.tsx b/src/components/react/ErrorBoundary.tsx new file mode 100644 index 0000000..4945094 --- /dev/null +++ b/src/components/react/ErrorBoundary.tsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import type { ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // Log error to console in development + if (import.meta.env.DEV) { + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + // In production, you could send to error tracking service + // Example: Sentry.captureException(error, { contexts: { react: errorInfo } }) + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( +
+

+ Something went wrong +

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+ ) + } + + return this.props.children + } +} diff --git a/src/components/react/SearchButton.tsx b/src/components/react/SearchButton.tsx new file mode 100644 index 0000000..17aad93 --- /dev/null +++ b/src/components/react/SearchButton.tsx @@ -0,0 +1,51 @@ +import React, { useState, useEffect } from 'react' +import { Search } from 'lucide-react' +import { Button } from '@/components/ui/button' +import SearchDialog from './SearchDialog' +import { ErrorBoundary } from './ErrorBoundary' + +const SearchButton: React.FC = () => { + const [isOpen, setIsOpen] = useState(false) + + // Keyboard shortcut: Cmd/Ctrl + K + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger if user is typing in an input, textarea, or contenteditable + const target = e.target as HTMLElement + const isInput = target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + + if ((e.metaKey || e.ctrlKey) && e.key === 'k' && !isInput) { + e.preventDefault() + setIsOpen(true) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + + return ( + + + + + + + ) +} + +export default SearchButton diff --git a/src/components/react/SearchDialog.tsx b/src/components/react/SearchDialog.tsx new file mode 100644 index 0000000..4c0d938 --- /dev/null +++ b/src/components/react/SearchDialog.tsx @@ -0,0 +1,979 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import { + Search, + Loader2, + FileText, + Calendar, + Hash, + Clock, + AlertCircle, + ChevronDown, + X, + Icon, +} from 'lucide-react' +import { Index } from 'flexsearch' +import { + Dialog, + DialogContent, + DialogDescription, +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' + +interface SearchPostResult { + id: string + title: string + description: string + date: string + tags: string[] + authors: string[] + url: string + content: string +} + +interface SearchEventResult { + id: string + title: string + description: string + date: string + type: string + url: string + content: string +} + +interface SearchResult { + posts: SearchPostResult[] + events: SearchEventResult[] +} + +type SearchItem = SearchPostResult | SearchEventResult + +interface SearchDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +// LocalStorage keys +const STORAGE_KEYS = { + SEARCH_INDEX: 'search_index', + SEARCH_INDEX_VERSION: 'search_index_version', + SEARCH_HISTORY: 'search_history', +} as const + +// Cache version - increment when index structure changes +const INDEX_VERSION = '1.0' + +const SEARCH_DEBOUNCE = 150 + +// Calculate relevance score +function calculateRelevanceScore( + result: SearchPostResult | SearchEventResult, + query: string, + queryLower: string, +): number { + let score = 0 + const titleLower = result.title.toLowerCase() + const descLower = result.description.toLowerCase() + const contentLower = result.content.toLowerCase() + let extraFields = '' + + if ('tags' in result) { + extraFields = (result.tags || []).join(' ') + } else if ('type' in result) { + extraFields = result.type || '' + } + + const extraLower = extraFields.toLowerCase() + + // Exact title match (highest priority) + if (titleLower === queryLower) { + score += 1000 + } else if (titleLower.startsWith(queryLower)) { + score += 500 + } else if (titleLower.includes(queryLower)) { + score += 300 + } + + // Title word matches + const titleWords = titleLower.split(/\s+/) + const queryWords = queryLower.split(/\s+/) + queryWords.forEach((qWord) => { + titleWords.forEach((tWord) => { + if (tWord === qWord) score += 50 + else if (tWord.startsWith(qWord)) score += 30 + else if (tWord.includes(qWord)) score += 10 + }) + }) + + // Description match + if (descLower.includes(queryLower)) { + score += 100 + } + queryWords.forEach((qWord) => { + if (descLower.includes(qWord)) score += 20 + }) + + // Tag/type matches + queryWords.forEach((qWord) => { + if (extraLower.includes(qWord)) score += 40 + }) + + // Content match (lower priority) + if (contentLower.includes(queryLower)) { + score += 10 + } + queryWords.forEach((qWord) => { + const matches = (contentLower.match(new RegExp(qWord, 'g')) || []).length + score += matches * 2 + }) + + // Recency boost (newer posts get slight boost) + try { + const postDate = new Date(result.date) + const now = new Date() + const daysSince = + (now.getTime() - postDate.getTime()) / (1000 * 60 * 60 * 24) + if (!isNaN(daysSince)) { + if (daysSince < 30) score += 5 + else if (daysSince < 90) score += 2 + } + } catch { + // Ignore date errors + } + + return score +} + +// Get search history from localStorage +function getSearchHistory(): string[] { + try { + const history = localStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY) + return history ? JSON.parse(history) : [] + } catch { + return [] + } +} + +// Save search to history +function saveSearchHistory(query: string): void { + if (!query.trim()) return + try { + const history = getSearchHistory() + const normalizedQuery = query.trim().toLowerCase() + // Remove if exists and add to front + const filtered = history.filter((q) => q.toLowerCase() !== normalizedQuery) + const updated = [normalizedQuery, ...filtered].slice(0, 10) // Keep last 10 + localStorage.setItem(STORAGE_KEYS.SEARCH_HISTORY, JSON.stringify(updated)) + } catch { + // Ignore localStorage errors + } +} + +// Get cached search index +function getCachedIndex(): { data: SearchResult; version: string } | null { + try { + const cached = localStorage.getItem(STORAGE_KEYS.SEARCH_INDEX) + const version = localStorage.getItem(STORAGE_KEYS.SEARCH_INDEX_VERSION) + if (cached && version === INDEX_VERSION) { + return { data: JSON.parse(cached), version } + } + } catch { + // Ignore errors + } + return null +} + +// Cache search index +function cacheIndex(data: SearchResult): void { + try { + localStorage.setItem(STORAGE_KEYS.SEARCH_INDEX, JSON.stringify(data)) + localStorage.setItem(STORAGE_KEYS.SEARCH_INDEX_VERSION, INDEX_VERSION) + } catch { + // Ignore localStorage errors (quota exceeded, etc.) + } +} + +// Extract content snippet around search terms +function extractSnippet( + content: string, + query: string, + maxLength: number = 150, +): string { + if (!content) return content ? content.substring(0, maxLength) : '' + if (!query) + return ( + content.substring(0, maxLength) + + (content.length > maxLength ? '...' : '') + ) + + const queryLower = query.toLowerCase() + const contentLower = content.toLowerCase() + const index = contentLower.indexOf(queryLower) + + if (index === -1) { + return ( + content.substring(0, maxLength) + + (content.length > maxLength ? '...' : '') + ) + } + + const start = Math.max(0, index - 50) + const end = Math.min(content.length, index + query.length + maxLength - 50) + let snippet = content.substring(start, end) + + if (start > 0) snippet = '...' + snippet + if (end < content.length) snippet = snippet + '...' + + return snippet +} + +const SearchDialog: React.FC = ({ open, onOpenChange }) => { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingIndex, setIsLoadingIndex] = useState(false) + const [error, setError] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const [searchIndex, setSearchIndex] = useState({ + posts: [], + events: [], + }) + const [index, setIndex] = useState(null) + const [displayedResults, setDisplayedResults] = useState(10) + const [recentPosts, setRecentPosts] = useState([]) + const [recentEvents, setRecentEvents] = useState([]) + const inputRef = useRef(null) + const resultsRef = useRef(null) + const scrollContainerRef = useRef(null) + const listboxId = 'search-results-listbox' + + // Load search index (with caching) + useEffect(() => { + if (!open) return + + let cancelled = false + + const loadSearchIndex = async () => { + // Check cache first + const cached = getCachedIndex() + if ( + cached && + (cached.data.posts.length > 0 || cached.data.events.length > 0) + ) { + setSearchIndex(cached.data) + setIsLoadingIndex(false) + + // Create FlexSearch index from cached data + const searchIndex = new Index({ + tokenize: 'forward', + }) + + cached.data.posts.forEach((item) => { + const searchableText = [ + item.title || '', + item.description || '', + (item.tags || []).join(' '), + item.content || '', + ] + .join(' ') + .toLowerCase() + searchIndex.add(`post-${item.id}`, searchableText) + }) + cached.data.events.forEach((item) => { + const searchableText = [ + item.title || '', + item.description || '', + item.type || '', + item.content || '', + ] + .join(' ') + .toLowerCase() + searchIndex.add(`event-${item.id}`, searchableText) + }) + + if (!cancelled) { + setIndex(searchIndex) + // Set recent posts from cached data + setRecentPosts(cached.data.posts.slice(0, 5)) + setRecentEvents(cached.data.events.slice(0, 5)) + } + return + } + + setIsLoadingIndex(true) + setError(null) + + try { + const response = await fetch('/api/search-index.json') + if (!response.ok) throw new Error('Failed to load search index') + const data: SearchResult = await response.json() + const posts: SearchPostResult[] = data['posts'] || [] + const events: SearchEventResult[] = data['events'] || [] + + if (cancelled) return + + // Cache the index + cacheIndex({ posts, events }) + + setSearchIndex({ posts, events }) + setRecentPosts(posts.slice(0, 5)) + setRecentEvents(events.slice(0, 5)) + + // Create FlexSearch index + const searchIndex = new Index({ + tokenize: 'forward', + }) + + // Index all fields + posts.forEach((item) => { + const searchableText = [ + item.title || '', + item.description || '', + (item.tags || []).join(' '), + item.content || '', + ] + .join(' ') + .toLowerCase() + searchIndex.add(`post-${item.id}`, searchableText) + }) + events.forEach((item) => { + const searchableText = [ + item.title || '', + item.description || '', + item.type || '', + item.content || '', + ] + .join(' ') + .toLowerCase() + searchIndex.add(`event-${item.id}`, searchableText) + }) + + if (!cancelled) { + setIndex(searchIndex) + } + } catch (error) { + if (!cancelled) { + console.error('Error loading search index:', error) + setError('Failed to load search index. Please try again later.') + } + } finally { + if (!cancelled) { + setIsLoadingIndex(false) + } + } + } + + // Only load if index hasn't been loaded yet + if (!index) { + loadSearchIndex() + } else { + if (recentPosts.length === 0 && searchIndex.posts.length > 0) { + setRecentPosts(searchIndex.posts.slice(0, 5)) + } + if (recentEvents.length === 0 && searchIndex.events.length > 0) { + setRecentEvents(searchIndex.events.slice(0, 5)) + } + } + + return () => { + cancelled = true + } + }, [ + open, + index, + searchIndex.posts.length, + searchIndex.events.length, + recentPosts.length, + recentEvents.length, + ]) + + // Perform search with fuzzy matching and improved relevance + const performSearch = useCallback( + (searchQuery: string) => { + if (!index || !searchQuery.trim()) { + setResults([]) + setSelectedIndex(0) + setDisplayedResults(10) + return + } + + setIsLoading(true) + setError(null) + const normalizedQuery = searchQuery.toLowerCase().trim() + + try { + // Save to search history + saveSearchHistory(searchQuery) + + // First try exact search + let searchResults: string[] = + (index.search(normalizedQuery, { + limit: 50, // Get more results for better sorting + }) as any) || [] + + // If no results, try fuzzy matching + if ( + (!searchResults || searchResults.length === 0) && + normalizedQuery.length > 2 + ) { + // Try searching with each word separately + const words = normalizedQuery.split(/\s+/) + const wordResults = new Set() + + words.forEach((word) => { + if (word.length > 2) { + const wordSearch = + (index.search(word, { limit: 20 }) as any) || [] + wordSearch.forEach((id: any) => wordResults.add(String(id))) + } + }) + + searchResults = Array.from(wordResults) + } + + // Map to actual items + const matchedResults = (searchResults || []) + .map((rawId) => { + const id = String(rawId) + + if (id.startsWith('post-')) { + const realId = id.replace('post-', '') + return searchIndex.posts.find((p) => p.id === realId) + } + + if (id.startsWith('event-')) { + const realId = id.replace('event-', '') + return searchIndex.events.find((e) => e.id === realId) + } + + return undefined + }) + .filter((item): item is SearchItem => Boolean(item)) + + // Calculate relevance scores and sort + const scoredResults = matchedResults.map((result) => ({ + result, + score: calculateRelevanceScore(result, searchQuery, normalizedQuery), + })) + + // Sort by score (descending) + scoredResults.sort((a, b) => b.score - a.score) + + // Extract results + const sortedResults = scoredResults.map((item) => item.result) + + setResults(sortedResults) + setSelectedIndex(0) + setDisplayedResults(10) // Reset to first 10 + } catch (error) { + console.error('Search error:', error) + setError('An error occurred while searching. Please try again.') + setResults([]) + } finally { + setIsLoading(false) + } + }, + [index, searchIndex], + ) + + // Debounced search + useEffect(() => { + if (!query.trim()) { + setResults([]) + setSelectedIndex(0) + setDisplayedResults(10) + return + } + + const timeoutId = setTimeout(() => { + performSearch(query) + }, SEARCH_DEBOUNCE) + + return () => clearTimeout(timeoutId) + }, [query, performSearch]) + + // Focus input when dialog opens and reset scroll position + useEffect(() => { + if (open) { + setTimeout(() => { + inputRef.current?.focus() + // Reset scroll position to top when dialog opens + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0 + } + }, 100) + setQuery('') + setResults([]) + setSelectedIndex(0) + setDisplayedResults(10) + setError(null) + } + }, [open]) + + // Reset scroll position when query changes (new search) + useEffect(() => { + if (scrollContainerRef.current && query.trim()) { + // Small delay to ensure DOM is updated + const timeoutId = setTimeout(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0 + } + }, 100) + return () => clearTimeout(timeoutId) + } + }, [query]) + + // Keyboard navigation + useEffect(() => { + if (!open) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + const maxIndex = Math.min(displayedResults - 1, results.length - 1) + setSelectedIndex((prev) => (prev < maxIndex ? prev + 1 : prev)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)) + } else if (e.key === 'Enter' && results[selectedIndex]?.url) { + e.preventDefault() + const url = results[selectedIndex].url + if (url) { + // Use proper navigation that preserves browser history + window.location.href = url + onOpenChange(false) + } + } else if (e.key === 'Escape') { + onOpenChange(false) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, results, selectedIndex, onOpenChange, displayedResults]) + + // Scroll selected result into view + useEffect(() => { + if (resultsRef.current && selectedIndex >= 0) { + const selectedElement = resultsRef.current.children[selectedIndex] as + | HTMLElement + | undefined + if (selectedElement) { + selectedElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }) + } + } + }, [selectedIndex]) + + // Highlight search terms in text + const highlightText = (text: string, query: string) => { + if (!query.trim() || !text) return text + + try { + // Escape special regex characters in query + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const parts = text.split(new RegExp(`(${escapedQuery})`, 'gi')) + return parts.map((part, i) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ) + } catch (error) { + // Fallback to plain text if regex fails + return text + } + } + + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString) + if (isNaN(date.getTime())) { + return dateString // Return original if invalid date + } + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } catch (error) { + return dateString // Return original on error + } + } + + const searchHistory = getSearchHistory() + const hasMoreResults = results.length > displayedResults + const visibleResults = results.slice(0, displayedResults) + + return ( + + + + Search blog posts by title, description, tags, or content + + {/* Live region for screen readers */} +
+ {isLoadingIndex && 'Loading search index...'} + {isLoading && 'Searching...'} + {!isLoading && + !isLoadingIndex && + query.trim() && + results.length > 0 && + `${results.length} result${results.length !== 1 ? 's' : ''} found`} + {!isLoading && + !isLoadingIndex && + query.trim() && + results.length === 0 && + 'No results found'} + {selectedIndex >= 0 && + results[selectedIndex] && + `Selected: ${results[selectedIndex].title}`} +
+
+ + setQuery(e.target.value)} + className="placeholder:text-muted-foreground flex h-16 w-full appearance-none border-0 bg-transparent py-4 pr-12 text-base shadow-none outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 sm:h-14 sm:py-3 sm:pr-0 sm:text-sm" + style={{ + WebkitAppearance: 'none', + MozAppearance: 'textfield', + border: 'none', + outline: 'none', + boxShadow: 'none', + }} + aria-label="Search blog posts" + aria-expanded={results.length > 0} + aria-controls={listboxId} + aria-activedescendant={ + results.length > 0 && selectedIndex >= 0 + ? `result-${selectedIndex}` + : undefined + } + role="combobox" + aria-autocomplete="list" + /> + {isLoading && ( + + )} +
+ + {/* Error message */} + {error && ( +
+ + {error} +
+ )} + +
+ {/* Loading skeleton */} + {isLoadingIndex && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {/* No query - show suggestions */} + {!query.trim() && !isLoadingIndex && ( +
+ {/* Recent posts */} + {recentPosts.length > 0 && ( + + )} + + {/* Recent events */} + {recentEvents.length > 0 && ( + + )} + + {/* Search history */} + {searchHistory.length > 0 && ( +
+

+ Recent Searches +

+
+ {searchHistory.slice(0, 5).map((term, idx) => ( + + ))} +
+
+ )} + + {/* Empty state if no suggestions */} + {recentPosts.length === 0 && + recentEvents.length === 0 && + searchHistory.length === 0 && ( +
+ +

+ Start typing to search posts... +

+

+ Search by title, description, tags, or content +

+
+ )} +
+ )} + + {/* No results */} + {query.trim() && + results.length === 0 && + !isLoading && + !isLoadingIndex && ( +
+ +

+ No results found for "{query}" +

+

+ Try different keywords or check your spelling +

+
+ )} + + {/* Results */} + {visibleResults.length > 0 && ( + + )} + + {/* Load more button */} + {hasMoreResults && ( +
+ +
+ )} + + {/* Extra padding at bottom for mobile to ensure last items are scrollable */} + + + {results.length > 0 && ( +
+
+ + + ↑↓ + {' '} + navigate + + + + ↵ + {' '} + select + + + + esc + {' '} + close + +
+
+ )} + +
+ ) +} + +export default SearchDialog diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..9dbeaa0 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/pages/api/search-index.json.ts b/src/pages/api/search-index.json.ts new file mode 100644 index 0000000..340c26a --- /dev/null +++ b/src/pages/api/search-index.json.ts @@ -0,0 +1,74 @@ +import type { APIRoute } from 'astro' +import { getAllEvents, getAllPosts } from '@/lib/data-utils' + +export const prerender = true + +export const GET: APIRoute = async () => { + try { + const posts = await getAllPosts() + const events = await getAllEvents() + + const searchIndex = { + "posts": posts.map((post) => { + // Extract text content from HTML body (remove tags) + // The body property contains the raw HTML content + const htmlContent = (post as { body?: string }).body || '' + const textContent = htmlContent + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + return { + id: post.id || '', + title: post.data.title || '', + description: post.data.description || '', + date: post.data.date?.toISOString() || new Date().toISOString(), + tags: post.data.tags || [], + authors: post.data.authors || [], + url: `/blog/${post.id}`, + // Include full content for better search results + content: textContent, // Full content for indexing + } + }), + "events": events.map((event) => { + // Extract text content from HTML body (remove tags) + // The body property contains the raw HTML content + const htmlContent = (event as { body?: string }).body || '' + const textContent = htmlContent + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + return { + id: event.id || '', + title: event.data.title || '', + description: event.data.description || '', + date: event.data.date?.toISOString() || new Date().toISOString(), + type: event.data.type, + url: `/events/${event.id}`, + // Include full content for better search results + content: textContent, // Full content for indexing + } + }), + } + + + return new Response(JSON.stringify(searchIndex), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + }, + }) + } catch (error) { + console.error('Error generating search index:', error) + // Return empty array on error instead of failing + return new Response(JSON.stringify([]), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + }, + }) + } +} -- cgit v1.2.3