feat(search): / 단축키 검색 모달 및 통합 검색 API 추가
- / 및 헤더 검색 클릭으로 모달을 열고 태그·게시물 검색을 제공. - 태그 검색 범위를 name/slug로 제한하고 IME 조합 입력 대응을 보강. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -60,6 +60,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.site-search-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -75,6 +79,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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 {
|
.site-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -96,6 +112,10 @@
|
|||||||
@apply px-6 py-4;
|
@apply px-6 py-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-search-modal__panel--animate {
|
||||||
|
animation: site-search-modal-in 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.post-prose {
|
.post-prose {
|
||||||
@apply max-w-none text-[17px] leading-8;
|
@apply max-w-none text-[17px] leading-8;
|
||||||
color: var(--site-text);
|
color: var(--site-text);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { menuOpen, toggleMenu, closeMenu } = useMenuState()
|
|||||||
const menuUserOpen = ref(false)
|
const menuUserOpen = ref(false)
|
||||||
const userMenuRef = ref(null)
|
const userMenuRef = ref(null)
|
||||||
const userMenuToggleRef = ref(null)
|
const userMenuToggleRef = ref(null)
|
||||||
|
const searchOpen = ref(false)
|
||||||
|
|
||||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
default: () => ({
|
default: () => ({
|
||||||
@@ -18,6 +19,37 @@ const closeUserMenu = () => {
|
|||||||
menuUserOpen.value = false
|
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}
|
* @returns {void}
|
||||||
@@ -47,24 +79,37 @@ const onDocumentClick = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape로 열린 패널을 닫는다(사용자 메뉴 우선, 이어서 모바일 좌측 메뉴).
|
* Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기).
|
||||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const onGlobalKeydown = (event) => {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (menuUserOpen.value) {
|
if (event.key !== '/') {
|
||||||
closeUserMenu()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!menuOpen.value) {
|
if (shouldIgnoreSearchHotkey(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
event.preventDefault()
|
||||||
closeMenu()
|
openSearchModal()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -114,11 +159,16 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||||
</NuxtLink>
|
</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-icon mr-2 text-lg leading-none">⌕</span>
|
||||||
<span class="site-header__search-text site-soft">Search</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>
|
<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">
|
<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">
|
<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
|
Subscribe
|
||||||
@@ -184,4 +234,5 @@ onBeforeUnmount(() => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<SiteSearchModal v-model="searchOpen" />
|
||||||
</template>
|
</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>
|
||||||
@@ -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
|
## 2026-05-11 v0.0.63
|
||||||
|
|
||||||
### Tailwind 엔트리 단일화
|
### Tailwind 엔트리 단일화
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
| 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/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
|
||||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
||||||
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||||
| server/api/tags.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/site-settings.get.js | 공개 사이트 설정 API |
|
||||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||||
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
||||||
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
|
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
|
||||||
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||||
- `Escape` 키는 사용자 드롭다운이 열려 있으면 우선 닫고, 그렇지 않으면 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||||
|
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||||
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
|
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
|
||||||
|
|
||||||
### 공개 화면 색상
|
### 공개 화면 색상
|
||||||
@@ -111,6 +112,7 @@ layouts/
|
|||||||
```
|
```
|
||||||
components/site/
|
components/site/
|
||||||
├── SiteHeader.vue # 상단 헤더
|
├── SiteHeader.vue # 상단 헤더
|
||||||
|
├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과)
|
||||||
├── LeftSidebar.vue # 왼쪽 사이드바
|
├── LeftSidebar.vue # 왼쪽 사이드바
|
||||||
├── RightSidebar.vue # 오른쪽 사이드바
|
├── RightSidebar.vue # 오른쪽 사이드바
|
||||||
├── MainColumn.vue # 메인 컬럼
|
├── MainColumn.vue # 메인 컬럼
|
||||||
@@ -296,6 +298,7 @@ components/content/
|
|||||||
- `GET /api/pages` - 고정 페이지 목록
|
- `GET /api/pages` - 고정 페이지 목록
|
||||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||||
- `GET /api/tags` - 태그 목록
|
- `GET /api/tags` - 태그 목록
|
||||||
|
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||||
- `GET /api/site-settings` - 공개 사이트 설정
|
- `GET /api/site-settings` - 공개 사이트 설정
|
||||||
- `GET /api/navigation` - 공개 네비게이션
|
- `GET /api/navigation` - 공개 네비게이션
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## v0.0.63
|
||||||
|
|
||||||
- `tailwindcss.cssPath`를 `~/assets/css/main.css`로 지정해, 없는 기본 경로 때문에 `node_modules/tailwindcss/tailwind.css`가 추가로 주입되던 이중 `@tailwind` 로딩을 제거.
|
- `tailwindcss.cssPath`를 `~/assets/css/main.css`로 지정해, 없는 기본 경로 때문에 `node_modules/tailwindcss/tailwind.css`가 추가로 주입되던 이중 `@tailwind` 로딩을 제거.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.63",
|
"version": "0.0.66",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
"#internal/nuxt/paths": "./.nuxt/paths.mjs"
|
"#internal/nuxt/paths": "./.nuxt/paths.mjs",
|
||||||
|
"#internal/nitro": {
|
||||||
|
"node": "./scripts/node-paths-nitro-shim.mjs"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-server.js",
|
"dev": "node scripts/dev-server.js",
|
||||||
|
|||||||
15
scripts/node-paths-nitro-shim.mjs
Normal file
15
scripts/node-paths-nitro-shim.mjs
Normal file
@@ -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 || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/api/search.get.js
Normal file
12
server/api/search.get.js
Normal 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 : '')
|
||||||
|
})
|
||||||
@@ -548,6 +548,82 @@ export const listTags = async () => {
|
|||||||
return rows.map(mapTagRow)
|
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>} 사이트 설정
|
* @returns {Promise<Object>} 사이트 설정
|
||||||
|
|||||||
Reference in New Issue
Block a user