From 3afb02dd0a32bc706e40f742abe4faef0eaaa18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:07:57 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20fuse.js,=20cmdk=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fuse.js: 퍼지 검색 기능 구현 - cmdk: 명령 팔레트 UI 컴포넌트 --- package.json | 2 + pnpm-lock.yaml | 455 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) diff --git a/package.json b/package.json index b260611..f95c6a0 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "@types/node-fetch": "^2.6.2", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", + "cmdk": "^1.1.1", "comma-number": "^2.1.0", "date-fns": "^2.29.3", + "fuse.js": "^7.1.0", "gray-matter": "^4.0.3", "image-size": "^1.0.2", "load-script": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ef1852..7df873a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,12 +36,18 @@ importers: '@vercel/speed-insights': specifier: ^1.1.0 version: 1.1.0(next@15.0.2(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) + cmdk: + specifier: ^1.1.1 + version: 1.1.1(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) comma-number: specifier: ^2.1.0 version: 2.1.0 date-fns: specifier: ^2.29.3 version: 2.29.3 + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -398,6 +404,177 @@ packages: resolution: {integrity: sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + 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.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + 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.14': + resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==} + 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.10': + resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} + 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.2': + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + 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.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + 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.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + 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.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + 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.4': + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + 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.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + 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.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + 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.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + 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.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + 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.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + 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.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + 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.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -640,6 +817,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'} @@ -797,6 +978,12 @@ packages: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} + 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 + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -925,6 +1112,9 @@ packages: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1238,6 +1428,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.1.0: + resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} + engines: {node: '>=10'} + get-intrinsic@1.2.2: resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==} @@ -1245,6 +1439,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} @@ -2125,6 +2323,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.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + 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-tweet@3.1.1: resolution: {integrity: sha512-8GQLa5y0G56kvGQkN7OiaKkjFAhWYVdyFq62ioY2qVtpMrjchVU+3KnqneCyp0+BemOQZkg6WWp/qoCNeEMH6A==} peerDependencies: @@ -2608,6 +2836,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.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -2931,6 +3179,149 @@ snapshots: tiny-glob: 0.2.9 tslib: 2.5.0 + '@radix-ui/primitive@1.1.2': {} + + '@radix-ui/react-compose-refs@1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-context@1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-dialog@1.1.14(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-context': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-dismissable-layer': 1.1.10(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-focus-guards': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-id': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-portal': 1.1.9(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-presence': 1.1.4(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-slot': 1.2.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + aria-hidden: 1.2.6 + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + react-remove-scroll: 2.7.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + + '@radix-ui/react-dismissable-layer@1.1.10(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + + '@radix-ui/react-focus-guards@1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-focus-scope@1.1.7(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + + '@radix-ui/react-id@1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-portal@1.1.9(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + + '@radix-ui/react-presence@1.1.4(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + + '@radix-ui/react-primitive@2.1.3(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + + '@radix-ui/react-slot@1.2.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-use-callback-ref@1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-use-controllable-state@1.2.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-use-effect-event@0.0.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-use-escape-keydown@1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-use-layout-effect@1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1)': + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.10.4': {} @@ -3169,6 +3560,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.5.0 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.0: @@ -3360,6 +3755,18 @@ snapshots: clsx@1.2.1: {} + cmdk@1.1.1(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-dialog': 1.1.14(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-id': 1.1.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -3473,6 +3880,8 @@ snapshots: detect-libc@2.0.3: optional: true + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -3997,6 +4406,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.1.0: {} + get-intrinsic@1.2.2: dependencies: function-bind: 1.1.2 @@ -4012,6 +4423,8 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 + get-nonce@1.0.1: {} + get-symbol-description@1.0.0: dependencies: call-bind: 1.0.5 @@ -5236,6 +5649,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1): + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + react-style-singleton: 2.2.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + tslib: 2.5.0 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + react-remove-scroll@2.7.1(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1): + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + react-remove-scroll-bar: 2.3.8(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + react-style-singleton: 2.2.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + tslib: 2.5.0 + use-callback-ref: 1.3.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + use-sidecar: 1.1.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + react-style-singleton@2.2.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1): + dependencies: + get-nonce: 1.0.1 + react: 19.0.0-rc-02c0e824-20241028 + tslib: 2.5.0 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + react-tweet@3.1.1(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028): dependencies: '@swc/helpers': 0.5.2 @@ -5939,6 +6379,21 @@ snapshots: dependencies: punycode: 2.3.0 + use-callback-ref@1.3.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1): + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + tslib: 2.5.0 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + use-sidecar@1.1.3(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.0.0-rc-02c0e824-20241028 + tslib: 2.5.0 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + use-sync-external-store@1.2.0(react@19.0.0-rc-02c0e824-20241028): dependencies: react: 19.0.0-rc-02c0e824-20241028 From ef56df9dccdba2b9db2ac1d0c78705979c33836e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:08:09 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fuse.js 기반 검색 인덱스 생성 및 실행 - 검색어 하이라이팅 유틸리티 함수 - 검색 기록 로컬스토리지 관리 - 검색 옵션: 퍼지 검색, 가중치 기반 필드 검색 --- app/lib/search.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 app/lib/search.ts diff --git a/app/lib/search.ts b/app/lib/search.ts new file mode 100644 index 0000000..66bd64d --- /dev/null +++ b/app/lib/search.ts @@ -0,0 +1,104 @@ +import Fuse from "fuse.js"; +import { Post } from "../get-posts"; +import type { FuseResult } from "fuse.js"; + +// Fuse.js 검색 옵션 설정 +const fuseOptions = { + // 검색할 필드와 가중치 + keys: [ + { name: "title", weight: 0.4 }, + { name: "tags", weight: 0.3 }, + { name: "description", weight: 0.2 }, + { name: "content", weight: 0.1 }, + ], + // 검색 정확도 (0 = 완벽한 매치, 1 = 모든 것 매치) + threshold: 0.3, + // 매칭된 부분을 포함할지 여부 + includeMatches: true, + // 검색 결과에 점수 포함 + includeScore: true, + // 최소 매치 문자 길이 + minMatchCharLength: 2, + // 오타 허용 + ignoreLocation: true, + // 대소문자 구분 안함 + isCaseSensitive: false, + // 필드 길이 정규화 + ignoreFieldNorm: false, + // 매치 위치 정보 + shouldSort: true, +}; + +// 검색 결과 타입 +export type SearchResult = FuseResult; + +// 검색 인덱스 생성 +export function createSearchIndex(posts: Post[]): Fuse { + return new Fuse(posts, fuseOptions); +} + +// 검색 실행 +export function searchPosts( + searchIndex: Fuse, + query: string, + limit: number = 10 +): SearchResult[] { + if (!query || query.trim() === "") { + return []; + } + + const results = searchIndex.search(query, { limit }); + return results; +} + +// 검색어 하이라이팅을 위한 유틸 함수 +export function highlightText( + text: string, + matches: SearchResult["matches"], + fieldName: string +): string { + const match = matches?.find(m => m.key === fieldName); + if (!match) return text; + + let result = text; + const indices = match.indices; + + for (const [start, end] of indices) { + result = + result.slice(0, start) + + "" + + result.slice(start, end + 1) + + "" + + result.slice(end + 1); + } + + return result; +} + +// 검색 기록 관리 +const SEARCH_HISTORY_KEY = "blog-search-history"; +const MAX_HISTORY_ITEMS = 5; + +export function getSearchHistory(): string[] { + if (typeof window === "undefined") return []; + + const history = localStorage.getItem(SEARCH_HISTORY_KEY); + return history ? JSON.parse(history) : []; +} + +export function addToSearchHistory(query: string): void { + if (typeof window === "undefined" || !query.trim()) return; + + const history = getSearchHistory(); + const newHistory = [query, ...history.filter(item => item !== query)].slice( + 0, + MAX_HISTORY_ITEMS + ); + + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory)); +} + +export function clearSearchHistory(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(SEARCH_HISTORY_KEY); +} From 11ddf0cba238ee157b9c4057c210fda0b21b8e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:08:17 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React Context 기반 전역 검색 상태 - 검색 모달 열기/닫기 상태 관리 - 키보드 단축키 (⌘K/Ctrl+K) 지원 - 검색 결과 및 로딩 상태 관리 - 디바운스된 검색 실행 (300ms) --- app/contexts/SearchContext.tsx | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 app/contexts/SearchContext.tsx diff --git a/app/contexts/SearchContext.tsx b/app/contexts/SearchContext.tsx new file mode 100644 index 0000000..4447a7d --- /dev/null +++ b/app/contexts/SearchContext.tsx @@ -0,0 +1,152 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from "react"; +import { useRouter } from "next/navigation"; +import Fuse from "fuse.js"; +import { Post } from "../get-posts"; +import { + createSearchIndex, + SearchResult, + addToSearchHistory, +} from "../lib/search"; + +interface SearchContextType { + isOpen: boolean; + openSearch: () => void; + closeSearch: () => void; + toggleSearch: () => void; + searchQuery: string; + setSearchQuery: React.Dispatch>; + searchResults: SearchResult[]; + isSearching: boolean; + searchIndex: Fuse | null; + initializeSearch: (posts: Post[]) => void; + selectedIndex: number; + setSelectedIndex: React.Dispatch>; + navigateToPost: (slug: string) => void; +} + +const SearchContext = createContext(undefined); + +export function useSearch() { + const context = useContext(SearchContext); + if (!context) { + throw new Error("useSearch must be used within a SearchProvider"); + } + return context; +} + +interface SearchProviderProps { + children: React.ReactNode; +} + +export function SearchProvider({ children }: SearchProviderProps) { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [searchIndex, setSearchIndex] = useState | null>(null); + const [selectedIndex, setSelectedIndex] = useState(0); + + const openSearch = useCallback(() => { + setIsOpen(true); + setSelectedIndex(0); + }, []); + + const closeSearch = useCallback(() => { + setIsOpen(false); + setSearchQuery(""); + setSearchResults([]); + setSelectedIndex(0); + }, []); + + const toggleSearch = useCallback(() => setIsOpen(prev => !prev), []); + + // 포스트로 이동하는 함수 + const navigateToPost = useCallback( + (slug: string) => { + closeSearch(); + router.push(`/${slug}`); + }, + [router, closeSearch] + ); + + // 검색 인덱스 초기화 + const initializeSearch = useCallback((posts: Post[]) => { + console.log("Creating search index with posts:", posts); + const index = createSearchIndex(posts); + console.log("Search index created:", index); + setSearchIndex(index); + }, []); + + // 검색 실행 (debounced) + useEffect(() => { + if (!searchIndex || !searchQuery.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + const timeoutId = setTimeout(() => { + const results = searchIndex.search(searchQuery, { limit: 10 }); + setSearchResults(results); + setIsSearching(false); + + // 검색 기록에 추가 + if (searchQuery.trim().length > 2) { + addToSearchHistory(searchQuery.trim()); + } + }, 300); // 300ms debounce + + return () => { + clearTimeout(timeoutId); + setIsSearching(false); + }; + }, [searchQuery, searchIndex]); + + // 단축키 설정 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+K (Mac) or Ctrl+K (Windows/Linux) + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + toggleSearch(); + } + + // ESC 키로 닫기 + if (e.key === "Escape" && isOpen) { + closeSearch(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, toggleSearch, closeSearch]); + + const value: SearchContextType = { + isOpen, + openSearch, + closeSearch, + toggleSearch, + searchQuery, + setSearchQuery, + searchResults, + isSearching, + searchIndex, + initializeSearch, + selectedIndex, + setSelectedIndex, + navigateToPost, + }; + + return ( + {children} + ); +} From 48f4d4710472b65ada1eb5750ebca8a3da72f764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:08:23 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=8F=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20API=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/posts GET 엔드포인트 구현 - 클라이언트 사이드 검색 인덱스 초기화를 위한 데이터 제공 - 서버 사이드 파일시스템 접근을 API로 추상화 --- app/api/posts/route.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 app/api/posts/route.ts diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts new file mode 100644 index 0000000..9fa0cb8 --- /dev/null +++ b/app/api/posts/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getPosts } from '../../get-posts'; + +export async function GET() { + try { + const posts = await getPosts(); + return NextResponse.json(posts); + } catch (error) { + console.error('Error fetching posts:', error); + return NextResponse.json( + { error: 'Failed to fetch posts' }, + { status: 500 } + ); + } +} \ No newline at end of file From 307cd56c7778921b38876a852cc0d173922a4de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:08:31 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmdk 기반 검색 인터페이스 - 실시간 검색 결과 표시 및 하이라이팅 - 키보드 네비게이션 (화살표, Enter) - 검색 기록 표시 및 재사용 - 다크모드 지원 스타일링 --- app/components/SearchModal.tsx | 352 +++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 app/components/SearchModal.tsx diff --git a/app/components/SearchModal.tsx b/app/components/SearchModal.tsx new file mode 100644 index 0000000..0e91973 --- /dev/null +++ b/app/components/SearchModal.tsx @@ -0,0 +1,352 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Command } from "cmdk"; +import { useSearch } from "../contexts/SearchContext"; +import { getSearchHistory, highlightText } from "../lib/search"; + +export function SearchModal() { + const { + isOpen, + closeSearch, + searchQuery, + setSearchQuery, + searchResults, + isSearching, + selectedIndex, + setSelectedIndex, + navigateToPost, + } = useSearch(); + + const [searchHistory, setSearchHistory] = useState([]); + + useEffect(() => { + if (isOpen) { + setSearchHistory(getSearchHistory()); + } + }, [isOpen]); + + // 선택 가능한 항목들 생성 + const selectableItems = [ + ...(!searchQuery + ? searchHistory.map(query => ({ type: "history" as const, data: query })) + : []), + ...(searchQuery + ? searchResults.map(result => ({ type: "result" as const, data: result })) + : []), + ]; + + // 선택 핸들러 + const handleSelect = (item: (typeof selectableItems)[0]) => { + if (item.type === "history") { + setSearchQuery(item.data); + } else if (item.type === "result") { + navigateToPost(item.data.item.slug); + } + }; + + // 키보드 네비게이션 핸들러 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex(prev => + Math.min(prev + 1, selectableItems.length - 1) + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (selectableItems[selectedIndex]) { + handleSelect(selectableItems[selectedIndex]); + } + break; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, selectedIndex, selectableItems, setSelectedIndex]); + + // selectedIndex를 선택 가능한 항목 수에 맞게 조정 + useEffect(() => { + if (selectedIndex >= selectableItems.length && selectableItems.length > 0) { + setSelectedIndex(0); + } + }, [selectableItems.length, selectedIndex, setSelectedIndex]); + + if (!isOpen) return null; + + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 모달 */} +
+ + {/* 검색 입력창 */} +
+ + + ESC 닫기 +
+ + {/* 검색 결과 */} + + {/* 검색중 표시 */} + {isSearching && ( +
+ 검색중... +
+ )} + + {/* 검색 기록 */} + {!searchQuery && searchHistory.length > 0 && ( + <> +
+ 최근 검색 +
+ {searchHistory.map((query, index) => { + const isSelected = selectedIndex === index; + return ( + + handleSelect({ type: "history", data: query }) + } + className={`flex items-center px-4 py-2 cursor-pointer rounded-md transition-colors ${ + isSelected + ? "bg-blue-100 dark:bg-blue-900/50 border-l-2 border-blue-500" + : "hover:bg-gray-100 dark:hover:bg-gray-800" + }`} + data-selected={isSelected} + > + + + {query} + + + ); + })} + + )} + + {/* 검색 결과 */} + {searchQuery && !isSearching && ( + <> + {searchResults.length > 0 ? ( + <> +
+ 검색 결과 ({searchResults.length}개) +
+ {searchResults.map((result, index) => { + const { item: post, matches } = result; + const year = new Date(post.date).getFullYear(); + const isSelected = selectedIndex === index; + + return ( + + handleSelect({ type: "result", data: result }) + } + className={`block px-4 py-3 cursor-pointer rounded-md transition-colors ${ + isSelected + ? "bg-blue-100 dark:bg-blue-900/50 border-l-2 border-blue-500" + : "hover:bg-gray-100 dark:hover:bg-gray-800" + }`} + data-selected={isSelected} + > +
+
+
+ +

+ + {year} + +

+ {post.description && ( +

+ )} + {post.content && ( +

+ )} + {post.tags && post.tags.length > 0 && ( +

+ {post.tags.map(tag => ( + + #{tag} + + ))} +
+ )} +
+
+
+ ); + })} + + ) : ( +
+ {`${searchQuery}에 대한 검색 결과가 없습니다`} +
+ )} + + )} + + {/* 검색어 없을 때 */} + {!searchQuery && searchHistory.length === 0 && ( +
+ 검색어를 입력하세요 +
+ )} +
+ + {/* 하단 도움말 */} +
+
+ ↑↓ 이동 + ↵ 선택 +
+ ⌘K 단축키 +
+
+
+ + ); +} + +// 아이콘 컴포넌트들 +function SearchIcon({ className = "" }) { + return ( + + + + ); +} + +function HistoryIcon({ className = "" }) { + return ( + + + + ); +} + +function PostIcon({ className = "" }) { + return ( + + + + ); +} From cecdaec3f384f195a2c71e1bc4607f514a766544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:08:38 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=9E=98=ED=8D=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API를 통한 포스트 데이터 로드 - 검색 인덱스 자동 초기화 - 컨텍스트 프로바이더 래핑 - 검색 모달 전역 렌더링 --- app/components/SearchProvider.tsx | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 app/components/SearchProvider.tsx diff --git a/app/components/SearchProvider.tsx b/app/components/SearchProvider.tsx new file mode 100644 index 0000000..daecf9a --- /dev/null +++ b/app/components/SearchProvider.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect } from "react"; +import { SearchProvider as SearchContextProvider } from "../contexts/SearchContext"; +import { SearchModal } from "./SearchModal"; +import type { Post } from "../get-posts"; +import { useSearch } from "../contexts/SearchContext"; + +// 검색 초기화를 위한 내부 컴포넌트 +function SearchInitializer() { + const { initializeSearch } = useSearch(); + + useEffect(() => { + async function loadPosts() { + try { + console.log("Starting to load posts for search..."); + const response = await fetch("/api/posts"); + if (!response.ok) { + throw new Error("Failed to fetch posts"); + } + const posts: Post[] = await response.json(); + console.log("Loaded posts for search:", posts); + initializeSearch(posts); + } catch (error) { + console.error("Failed to load posts for search:", error); + } + } + + loadPosts(); + }, [initializeSearch]); + + return null; +} + +// SearchProvider 래퍼 컴포넌트 +export function SearchProviderWrapper({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} From 4fa126f04c8c9a928032e1afbe7d3135f4863986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=ED=95=98?= Date: Tue, 12 Aug 2025 18:08:46 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=ED=97=A4=EB=8D=94=EC=97=90=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 아이콘 버튼 추가 - 키보드 단축키 힌트 표시 (⌘K) - 클라이언트 컴포넌트로 변경 - SearchContext와 연동 --- app/header.tsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/header.tsx b/app/header.tsx index 96b5df5..c791a75 100644 --- a/app/header.tsx +++ b/app/header.tsx @@ -1,8 +1,13 @@ +'use client'; + import { ThemeToggle } from "./theme-toggle"; import { Logo } from "./logo"; import Link from "next/link"; +import { useSearch } from "./contexts/SearchContext"; export function Header() { + const { openSearch } = useSearch(); + return (
@@ -10,6 +15,16 @@ export function Header() {