From ff6526c9973d5711fc2a19e2a77b97d0fab70497 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 11 May 2026 16:12:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(search):=20/=20=EB=8B=A8=EC=B6=95=ED=82=A4?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EB=AA=A8=EB=8B=AC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EA=B2=80=EC=83=89=20API=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 - / 및 헤더 검색 클릭으로 모달을 열고 태그·게시물 검색을 제공. - 태그 검색 범위를 name/slug로 제한하고 IME 조합 입력 대응을 보강. Co-authored-by: Cursor --- assets/css/main.css | 20 ++ components/site/SiteHeader.vue | 71 +++++- components/site/SiteSearchModal.vue | 254 ++++++++++++++++++++++ docs/history.md | 6 + docs/map.md | 4 +- docs/spec.md | 5 +- docs/update.md | 15 ++ package.json | 7 +- scripts/node-paths-nitro-shim.mjs | 15 ++ server/api/search.get.js | 12 + server/repositories/content-repository.js | 76 +++++++ 11 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 components/site/SiteSearchModal.vue create mode 100644 scripts/node-paths-nitro-shim.mjs create mode 100644 server/api/search.get.js diff --git a/assets/css/main.css b/assets/css/main.css index 8005a4c..49650ac 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -60,6 +60,10 @@ overflow: hidden; } + html.site-search-open { + overflow: hidden; + } + body { min-width: 320px; margin: 0; @@ -75,6 +79,18 @@ } @layer components { + @keyframes site-search-modal-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.99); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + .site-shell { display: flex; flex-direction: column; @@ -96,6 +112,10 @@ @apply px-6 py-4; } + .site-search-modal__panel--animate { + animation: site-search-modal-in 0.18s ease-out; + } + .post-prose { @apply max-w-none text-[17px] leading-8; color: var(--site-text); diff --git a/components/site/SiteHeader.vue b/components/site/SiteHeader.vue index 0c09baa..1cc58e7 100644 --- a/components/site/SiteHeader.vue +++ b/components/site/SiteHeader.vue @@ -3,6 +3,7 @@ const { menuOpen, toggleMenu, closeMenu } = useMenuState() const menuUserOpen = ref(false) const userMenuRef = ref(null) const userMenuToggleRef = ref(null) +const searchOpen = ref(false) const { data: siteSettings } = await useFetch('/api/site-settings', { default: () => ({ @@ -18,6 +19,37 @@ const closeUserMenu = () => { menuUserOpen.value = false } +/** + * 통합 검색 모달을 연다. + * @returns {void} + */ +const openSearchModal = () => { + searchOpen.value = true +} + +/** + * 입력 필드에 포커스가 있으면 `/` 검색 단축키를 무시한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {boolean} 무시할 때 true + */ +const shouldIgnoreSearchHotkey = (event) => { + if (event.ctrlKey || event.metaKey || event.altKey) { + return true + } + const target = event.target + if (!(target instanceof HTMLElement)) { + return false + } + const tag = target.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + return true + } + if (target.isContentEditable) { + return true + } + return false +} + /** * 사용자 메뉴를 토글한다. * @returns {void} @@ -47,24 +79,37 @@ const onDocumentClick = (event) => { } /** - * Escape로 열린 패널을 닫는다(사용자 메뉴 우선, 이어서 모바일 좌측 메뉴). + * Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기). * @param {KeyboardEvent} event - 키보드 이벤트 * @returns {void} */ const onGlobalKeydown = (event) => { - if (event.key !== 'Escape') { + if (event.key === 'Escape') { + if (searchOpen.value) { + searchOpen.value = false + event.preventDefault() + return + } + if (menuUserOpen.value) { + closeUserMenu() + return + } + if (!menuOpen.value) { + return + } + if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) { + closeMenu() + } return } - if (menuUserOpen.value) { - closeUserMenu() + if (event.key !== '/') { return } - if (!menuOpen.value) { + if (shouldIgnoreSearchHotkey(event)) { return } - if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) { - closeMenu() - } + event.preventDefault() + openSearchModal() } onMounted(() => { @@ -114,11 +159,16 @@ onBeforeUnmount(() => { {{ siteSettings.title }} - + + diff --git a/components/site/SiteSearchModal.vue b/components/site/SiteSearchModal.vue new file mode 100644 index 0000000..1506472 --- /dev/null +++ b/components/site/SiteSearchModal.vue @@ -0,0 +1,254 @@ + + + diff --git a/docs/history.md b/docs/history.md index b54c036..1819191 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-11 v0.0.65 + +### 통합 검색 모달과 `GET /api/search` + +헤더 검색은 장식이 아니라 Ghost류 UX로 `/` 단축키·모달·태그·게시물 섹션 구분이 필요했다. `INPUT`/`TEXTAREA` 등에 포커스가 있을 때는 브라우저 입력과 충돌하지 않도록 `/`를 무시한다. 검색은 저장소 `searchPublicContent`에 모아 `LIKE` 대신 `position(lower(q) in lower(column))`로 부분 일치를 구현해 `%`·`_` 이스케이프 이슈를 줄였다. 저자(author) 검색은 현재 도메인 모델에 없어 제외했다. + ## 2026-05-11 v0.0.63 ### Tailwind 엔트리 단일화 diff --git a/docs/map.md b/docs/map.md index ae4c559..a13a1d3 100644 --- a/docs/map.md +++ b/docs/map.md @@ -28,7 +28,8 @@ | 파일 | 화면 위치 | |------|-----------| | components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) | -| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭 | +| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 | +| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 | | components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` | | components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` | | components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 | @@ -110,6 +111,7 @@ | server/api/pages.get.js | 고정 페이지 목록 샘플 API | | server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API | | server/api/tags.get.js | 태그 목록 샘플 API | +| server/api/search.get.js | 통합 검색 API(`q` 쿼리) | | server/api/site-settings.get.js | 공개 사이트 설정 API | | server/api/navigation.get.js | 공개 네비게이션 API | | server/routes/admin/api/auth/login.post.js | 관리자 로그인 API | diff --git a/docs/spec.md b/docs/spec.md index bb4a17a..0d77437 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -35,7 +35,8 @@ - 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장 - 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용 - `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다. -- `Escape` 키는 사용자 드롭다운이 열려 있으면 우선 닫고, 그렇지 않으면 모바일에서만 좌측 슬라이드 메뉴를 닫는다. +- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다. +- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다. - 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다. ### 공개 화면 색상 @@ -111,6 +112,7 @@ layouts/ ``` components/site/ ├── SiteHeader.vue # 상단 헤더 +├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과) ├── LeftSidebar.vue # 왼쪽 사이드바 ├── RightSidebar.vue # 오른쪽 사이드바 ├── MainColumn.vue # 메인 컬럼 @@ -296,6 +298,7 @@ components/content/ - `GET /api/pages` - 고정 페이지 목록 - `GET /api/pages/:slug` - 고정 페이지 상세 - `GET /api/tags` - 태그 목록 +- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/site-settings` - 공개 사이트 설정 - `GET /api/navigation` - 공개 네비게이션 diff --git a/docs/update.md b/docs/update.md index c34f3d0..8607f5a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,20 @@ # 업데이트 이력 +## v0.0.66 + +- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임. +- 검색 모달 헤더 아이콘은 입력 비어있으면 돋보기, 입력이 있으면 X(클리어)로 전환하고 클릭 시 입력값을 비운다. 좌측/우측 닫기 X는 제거하고 `Esc`·백드롭 클릭·모바일 취소로 닫는다. +- 검색 입력은 IME(한글 조합) 중에도 디바운스로 검색을 갱신해 `워`처럼 조합 상태가 유지되는 입력에서도 결과가 나오게 하고, 조합 종료 시점에는 확정값으로 즉시 한 번 더 갱신한다. + +## v0.0.65 + +- 헤더 `/` 단축키·검색 영역 클릭으로 통합 검색 모달(`SiteSearchModal`)을 연다. `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable` 포커스일 때는 `/`를 가로채지 않는다. +- `GET /api/search?q=`와 `searchPublicContent`(저장소)로 태그·게시물(제목·요약·본문) 부분 일치 검색, 모달에서 Tags·Posts 섹션·일치 구간 강조·`html.site-search-open` 스크롤 잠금. + +## v0.0.64 + +- 비개발용 `paths.mjs`가 `#internal/nitro`를 import하는데 루트 `package.json` `imports`에 없어 `Package import specifier "#internal/nitro" is not defined`가 나던 문제를, `scripts/node-paths-nitro-shim.mjs`로 최소 `useRuntimeConfig().app`만 제공하고 `#internal/nitro`를 매핑해 해결. + ## v0.0.63 - `tailwindcss.cssPath`를 `~/assets/css/main.css`로 지정해, 없는 기본 경로 때문에 `node_modules/tailwindcss/tailwind.css`가 추가로 주입되던 이중 `@tailwind` 로딩을 제거. diff --git a/package.json b/package.json index e0acdef..10a1bb8 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "sori.studio", - "version": "0.0.63", + "version": "0.0.66", "private": true, "type": "module", "imports": { - "#internal/nuxt/paths": "./.nuxt/paths.mjs" + "#internal/nuxt/paths": "./.nuxt/paths.mjs", + "#internal/nitro": { + "node": "./scripts/node-paths-nitro-shim.mjs" + } }, "scripts": { "dev": "node scripts/dev-server.js", diff --git a/scripts/node-paths-nitro-shim.mjs b/scripts/node-paths-nitro-shim.mjs new file mode 100644 index 0000000..60bdeef --- /dev/null +++ b/scripts/node-paths-nitro-shim.mjs @@ -0,0 +1,15 @@ +/** + * 루트 `package.json`의 `imports`로 Node가 직접 해석할 때만 쓰인다. + * 비개발용 `.nuxt/paths.mjs`가 `useRuntimeConfig().app`만 참조하므로 해당 형태만 맞춘다. + * @returns {{ app: { baseURL: string, buildAssetsDir: string, cdnURL: string } }} 최소 런타임 설정 + */ +export function useRuntimeConfig () { + const env = process.env + return { + app: { + baseURL: env.NUXT_APP_BASE_URL || env.NUXT_PUBLIC_APP_BASE_URL || '/', + buildAssetsDir: env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/', + cdnURL: env.NUXT_APP_CDN_URL || env.NUXT_PUBLIC_CDN_URL || '' + } + } +} diff --git a/server/api/search.get.js b/server/api/search.get.js new file mode 100644 index 0000000..a79db7f --- /dev/null +++ b/server/api/search.get.js @@ -0,0 +1,12 @@ +import { searchPublicContent } from '../repositories/content-repository' + +/** + * 공개 통합 검색 API(태그·게시물) + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 검색 결과 + */ +export default defineEventHandler(async (event) => { + const raw = getQuery(event).q + const q = Array.isArray(raw) ? raw[0] : raw + return searchPublicContent(typeof q === 'string' ? q : '') +}) diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js index f685e0e..686e4f3 100644 --- a/server/repositories/content-repository.js +++ b/server/repositories/content-repository.js @@ -548,6 +548,82 @@ export const listTags = async () => { return rows.map(mapTagRow) } +const SEARCH_TAG_LIMIT = 12 +const SEARCH_POST_LIMIT = 12 + +/** + * 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시) + * @param {string} rawQuery - 검색어 + * @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 태그·게시물 요약 결과 + */ +export const searchPublicContent = async (rawQuery) => { + const q = String(rawQuery || '').trim() + if (!q) { + return { tags: [], posts: [] } + } + + const sql = getPostgresClient() + + if (!sql) { + const needle = q.toLowerCase() + const posts = getSamplePosts() + .filter((post) => { + const hay = `${post.title}\n${post.excerpt || ''}\n${post.content || ''}`.toLowerCase() + return hay.includes(needle) + }) + .slice(0, SEARCH_POST_LIMIT) + .map((post) => ({ + slug: post.slug, + title: post.title, + excerpt: post.excerpt || '' + })) + const tags = getSampleTags() + .filter((tag) => { + const hay = `${tag.name}\n${tag.slug}`.toLowerCase() + return hay.includes(needle) + }) + .slice(0, SEARCH_TAG_LIMIT) + .map((tag) => ({ + name: tag.name, + slug: tag.slug + })) + return { tags, posts } + } + + const tagRows = await sql` + SELECT name, slug + FROM tags + WHERE + position(lower(${q}) in lower(name)) > 0 + OR position(lower(${q}) in lower(slug)) > 0 + ORDER BY sort_order ASC, name ASC + LIMIT ${SEARCH_TAG_LIMIT} + ` + + const postRows = await sql` + SELECT posts.slug, posts.title, posts.excerpt + FROM posts + WHERE posts.status = 'published' + AND (posts.published_at IS NULL OR posts.published_at <= now()) + AND ( + position(lower(${q}) in lower(posts.title)) > 0 + OR position(lower(${q}) in lower(coalesce(posts.excerpt, ''))) > 0 + OR position(lower(${q}) in lower(posts.content)) > 0 + ) + ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC + LIMIT ${SEARCH_POST_LIMIT} + ` + + return { + tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })), + posts: postRows.map((row) => ({ + slug: row.slug, + title: row.title, + excerpt: row.excerpt || '' + })) + } +} + /** * 사이트 설정 조회 * @returns {Promise} 사이트 설정