feat(search): / 단축키 검색 모달 및 통합 검색 API 추가

- / 및 헤더 검색 클릭으로 모달을 열고 태그·게시물 검색을 제공.
- 태그 검색 범위를 name/slug로 제한하고 IME 조합 입력 대응을 보강.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 16:12:31 +09:00
parent bcf3acd432
commit ff6526c997
11 changed files with 471 additions and 14 deletions

12
server/api/search.get.js Normal file
View File

@@ -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 : '')
})

View File

@@ -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<Object>} 사이트 설정