feat(search): / 단축키 검색 모달 및 통합 검색 API 추가
- / 및 헤더 검색 클릭으로 모달을 열고 태그·게시물 검색을 제공. - 태그 검색 범위를 name/slug로 제한하고 IME 조합 입력 대응을 보강. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(() => {
|
||||
</button>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
<div class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 items-center rounded-lg px-3 text-sm md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input">
|
||||
<button
|
||||
type="button"
|
||||
class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
|
||||
aria-label="검색 열기"
|
||||
@click="openSearchModal"
|
||||
>
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||
<span class="site-header__search-text site-soft">Search</span>
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</div>
|
||||
</button>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
<NuxtLink class="site-header__buy site-accent-button shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold sm:px-4 sm:py-2 sm:text-sm" to="/pages/about">
|
||||
Subscribe
|
||||
@@ -184,4 +234,5 @@ onBeforeUnmount(() => {
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<SiteSearchModal v-model="searchOpen" />
|
||||
</template>
|
||||
|
||||
254
components/site/SiteSearchModal.vue
Normal file
254
components/site/SiteSearchModal.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<script setup>
|
||||
/**
|
||||
* @typedef {{ name: string, slug: string }} SearchTagHit
|
||||
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit
|
||||
*/
|
||||
|
||||
const open = defineModel({ type: Boolean, default: false })
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const query = ref('')
|
||||
const debouncedQuery = ref('')
|
||||
const isComposing = ref(false)
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let debounceTimer = null
|
||||
|
||||
watch(query, (value) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debouncedQuery.value = value
|
||||
}, 200)
|
||||
})
|
||||
|
||||
const { data, pending } = await useFetch('/api/search', {
|
||||
query: { q: debouncedQuery },
|
||||
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
||||
})
|
||||
|
||||
/**
|
||||
* 정규식 특수 문자 이스케이프
|
||||
* @param {string} value - 원문
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*/
|
||||
const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* 검색어 일치 구간을 나누어 하이라이트 표시에 사용한다.
|
||||
* @param {string} text - 표시할 문자열
|
||||
* @param {string} needle - 검색어
|
||||
* @returns {Array<{ text: string, hit: boolean }>} 구간 목록
|
||||
*/
|
||||
const highlightParts = (text, needle) => {
|
||||
const base = String(text ?? '')
|
||||
const q = String(needle ?? '').trim()
|
||||
if (!q) {
|
||||
return [{ text: base, hit: false }]
|
||||
}
|
||||
const parts = base.split(new RegExp(`(${escapeRegExp(q)})`, 'gi'))
|
||||
return parts
|
||||
.filter((part) => part !== '')
|
||||
.map((part, index) => ({
|
||||
text: part,
|
||||
hit: index % 2 === 1
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록용 요약 문자열을 한 줄로 자른다.
|
||||
* @param {string} text - 요약 원문
|
||||
* @param {number} max - 최대 글자 수
|
||||
* @returns {string} 잘린 문자열
|
||||
*/
|
||||
const clipExcerpt = (text, max = 140) => {
|
||||
const line = String(text ?? '').replace(/\s+/g, ' ').trim()
|
||||
if (line.length <= max) {
|
||||
return line
|
||||
}
|
||||
return `${line.slice(0, max)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* 배경 클릭 시 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBackdropPointerDown = () => {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어를 비운다(입력 포커스 유지).
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearQuery = () => {
|
||||
query.value = ''
|
||||
debouncedQuery.value = ''
|
||||
nextTick(() => {
|
||||
const el = searchInputRef.value
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 시작 처리
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionStart = () => {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionEnd = () => {
|
||||
isComposing.value = false
|
||||
clearTimeout(debounceTimer)
|
||||
debouncedQuery.value = query.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 열림·닫힘에 따라 스크롤 잠금과 입력을 동기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
watch(open, (isOpen) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
document.documentElement.classList.toggle('site-search-open', Boolean(isOpen))
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
const el = searchInputRef.value
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
query.value = ''
|
||||
debouncedQuery.value = ''
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('site-search-open')
|
||||
}
|
||||
clearTimeout(debounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="open"
|
||||
class="site-search-modal fixed inset-0 z-[60] flex justify-center bg-black/35 px-3 pt-14 pb-8 backdrop-blur-sm sm:px-4 sm:pt-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="사이트 검색"
|
||||
@pointerdown.self="onBackdropPointerDown"
|
||||
>
|
||||
<div
|
||||
class="site-search-modal__panel site-search-modal__panel--animate flex h-[min(78vh,640px)] w-full max-w-[95vw] flex-col overflow-hidden rounded-lg border border-[var(--site-line)] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_20px_50px_rgba(0,0,0,0.18)] sm:max-w-lg"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="site-search-modal__header flex shrink-0 items-center gap-2 border-b border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-3 sm:gap-3 sm:px-5 sm:py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="site-search-modal__icon flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-[var(--site-text)] transition-colors hover:bg-[var(--site-panel)]"
|
||||
:aria-label="query.trim() ? '검색어 지우기' : '검색'"
|
||||
:disabled="!query.trim()"
|
||||
@click="query.trim() ? clearQuery() : null"
|
||||
>
|
||||
<svg v-if="!query.trim()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M23.38 21.62l-6.53-6.53a9.15 9.15 0 0 0 1.9-5.59 9.27 9.27 0 1 0-3.66 7.36l6.53 6.53a1.26 1.26 0 0 0 1.76 0 1.25 1.25 0 0 0 0-1.77ZM2.75 9.5A6.75 6.75 0 1 1 9.5 16.25 6.76 6.76 0 0 1 2.75 9.5Z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
enterkeyhint="search"
|
||||
autocomplete="off"
|
||||
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
||||
placeholder="글 제목, 본문, 태그 검색"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
/>
|
||||
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
|
||||
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
|
||||
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="pending" class="text-sm text-[var(--site-soft)]">
|
||||
검색 중…
|
||||
</div>
|
||||
<template v-else>
|
||||
<section v-if="(data?.tags?.length ?? 0) > 0" class="site-search-modal__section mb-6">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Tags
|
||||
</h2>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="tag in data.tags" :key="tag.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__tag-link flex items-baseline gap-1 rounded-md px-2 py-2 text-[15px] transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/tag/${tag.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="text-[var(--site-soft)]">#</span>
|
||||
<span class="font-medium text-[var(--site-text)]">{{ tag.name }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Posts
|
||||
</h2>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="post in data.posts" :key="post.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__post-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/post/${post.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||
<template v-for="(seg, i) in highlightParts(post.title, query)" :key="`t-${post.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="clipExcerpt(post.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
|
||||
<template v-for="(seg, i) in highlightParts(clipExcerpt(post.excerpt), query)" :key="`e-${post.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
|
||||
일치하는 결과가 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user