First commit
This commit is contained in:
284
frontend/app/components/SearchModal.vue
Normal file
284
frontend/app/components/SearchModal.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
|
||||
/* Centered search popup: the sidebar search bar (or Ctrl/⌘+K, or "/"
|
||||
on pages without their own search box) opens it, then it live-searches
|
||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
const { open, show, hide, toggle } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { viewed } = useRecentlyViewed()
|
||||
|
||||
const MAX_RESULTS = 8
|
||||
|
||||
const q = ref('')
|
||||
const results = ref<ArticleMeta[]>([])
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const trimmed = computed(() => q.value.trim())
|
||||
|
||||
/* --- focus: the field takes focus on open, focus returns on close --- */
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
|
||||
watch(open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
inputEl.value?.focus()
|
||||
} else {
|
||||
previouslyFocused?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
/* --- live search: debounce typing, discard stale responses --- */
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestSeq = 0
|
||||
|
||||
watch(trimmed, () => {
|
||||
clearTimeout(debounceTimer)
|
||||
activeIndex.value = 0
|
||||
if (!trimmed.value) {
|
||||
results.value = []
|
||||
loading.value = false
|
||||
failed.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
debounceTimer = setTimeout(runSearch, 180)
|
||||
})
|
||||
|
||||
async function runSearch() {
|
||||
const seq = ++requestSeq
|
||||
try {
|
||||
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, {
|
||||
query: { q: trimmed.value },
|
||||
})
|
||||
if (seq !== requestSeq) return // a newer search already won
|
||||
results.value = res.slice(0, MAX_RESULTS)
|
||||
failed.value = false
|
||||
} catch {
|
||||
if (seq !== requestSeq) return
|
||||
failed.value = true
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* --- keyboard: global shortcuts, plus arrows + enter inside the field --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
hideSettings()
|
||||
hideProfile()
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape' && open.value) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
/* "/" also opens the popup — except on the home page, where the key
|
||||
focuses the hero's inline search, and while typing in a field */
|
||||
if (e.key === '/' && !open.value && route.path !== '/') {
|
||||
const t = e.target
|
||||
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
|
||||
e.preventDefault()
|
||||
hideSettings()
|
||||
hideProfile()
|
||||
show()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
/* where a result leads: the language address of the version it shows */
|
||||
function articleHref(a: ArticleMeta): string {
|
||||
return versionHref(identityOf(a), { language: articleLanguage(a), slug: a.slug })
|
||||
}
|
||||
|
||||
function openArticle(article: ArticleMeta) {
|
||||
hide()
|
||||
void router.push(articleHref(article))
|
||||
}
|
||||
|
||||
function openAllResults() {
|
||||
hide()
|
||||
void router.push({ path: '/', query: { q: trimmed.value } })
|
||||
}
|
||||
|
||||
function onFieldKeydown(e: KeyboardEvent) {
|
||||
const n = results.value.length
|
||||
if (e.key === 'ArrowDown' && n) {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % n
|
||||
} else if (e.key === 'ArrowUp' && n) {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value - 1 + n) % n
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const chosen = results.value[activeIndex.value] ?? results.value[0]
|
||||
if (chosen) openArticle(chosen)
|
||||
else if (trimmed.value) openAllResults()
|
||||
}
|
||||
}
|
||||
|
||||
/* keep the highlighted row visible while arrowing through the list */
|
||||
watch(activeIndex, async () => {
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector('.search-modal .search-result.active')
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
/* navigating away (clicking any link in the popup) closes it */
|
||||
watch(() => route.fullPath, () => {
|
||||
if (open.value) hide()
|
||||
})
|
||||
|
||||
/* split text into plain / matched parts so results can highlight the term */
|
||||
interface Part {
|
||||
text: string
|
||||
hit: boolean
|
||||
}
|
||||
function highlight(text: string): Part[] {
|
||||
const term = trimmed.value.toLowerCase()
|
||||
if (!term) return [{ text, hit: false }]
|
||||
const parts: Part[] = []
|
||||
const lower = text.toLowerCase()
|
||||
let from = 0
|
||||
for (;;) {
|
||||
const idx = lower.indexOf(term, from)
|
||||
if (idx === -1) break
|
||||
if (idx > from) parts.push({ text: text.slice(from, idx), hit: false })
|
||||
parts.push({ text: text.slice(idx, idx + term.length), hit: true })
|
||||
from = idx + term.length
|
||||
}
|
||||
if (from < text.length) parts.push({ text: text.slice(from), hit: false })
|
||||
return parts
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="search-popup">
|
||||
<div v-if="open" class="search-overlay" @click.self="hide()">
|
||||
<div class="search-modal" role="dialog" aria-modal="true" :aria-label="t('common.searchArticles')">
|
||||
<div class="search-modal-field">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="q"
|
||||
class="search-modal-input"
|
||||
type="text"
|
||||
:placeholder="t('search.placeholder')"
|
||||
:aria-label="t('common.searchArticles')"
|
||||
autocomplete="off"
|
||||
@keydown="onFieldKeydown"
|
||||
/>
|
||||
<span v-if="loading" class="search-spinner" aria-hidden="true" />
|
||||
<button
|
||||
v-else-if="q"
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:aria-label="t('search.clear')"
|
||||
@click="q = ''"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-modal-body">
|
||||
<div v-if="failed" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span>{{ t('search.apiDown') }}</span>
|
||||
</div>
|
||||
|
||||
<ul v-else-if="results.length" class="search-results" role="listbox">
|
||||
<li v-for="(a, i) in results" :key="a._id" role="option" :aria-selected="i === activeIndex">
|
||||
<NuxtLink
|
||||
:to="articleHref(a)"
|
||||
class="search-result"
|
||||
:class="{ active: i === activeIndex }"
|
||||
@mouseenter="activeIndex = i"
|
||||
@click.prevent="openArticle(a)"
|
||||
>
|
||||
<span class="sr-title" :lang="articleLanguage(a)">
|
||||
<template v-for="(p, pi) in highlight(a.title)" :key="pi">
|
||||
<mark v-if="p.hit">{{ p.text }}</mark>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span class="sr-meta">
|
||||
<span>{{ articleHref(a) }}</span>
|
||||
<span v-if="a.tags?.length" class="tags">
|
||||
<span v-for="t in a.tags.slice(0, 3)" :key="t" class="tag">{{ t }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="articleLanguage(a) !== 'en'"
|
||||
class="lang-badge"
|
||||
:title="t('common.writtenIn', { lang: languageName(articleLanguage(a)) })"
|
||||
>{{ languageName(articleLanguage(a)) }}</span
|
||||
>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else-if="loading">
|
||||
<div v-for="i in 3" :key="i" class="search-skeleton" />
|
||||
</div>
|
||||
|
||||
<!-- idle state: quick jumps to recently viewed articles -->
|
||||
<div v-else-if="!trimmed && viewed.length" class="search-quick">
|
||||
<div class="search-quick-title">{{ t('recent.title') }}</div>
|
||||
<ul class="search-results">
|
||||
<li v-for="v in viewed.slice(0, 6)" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`" class="search-result">
|
||||
<span class="sr-title">{{ v.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="trimmed" class="search-none" v-html="th('search.noMatch', { q: trimmed })"></div>
|
||||
|
||||
<div v-else class="search-none">{{ t('search.idle') }}</div>
|
||||
</div>
|
||||
|
||||
<footer class="search-modal-foot">
|
||||
<span class="foot-hints">
|
||||
<span><kbd class="kbd-hint">↑</kbd><kbd class="kbd-hint">↓</kbd> {{ t('search.navigate') }}</span>
|
||||
<span><kbd class="kbd-hint">↵</kbd> {{ t('search.open') }}</span>
|
||||
<span><kbd class="kbd-hint">esc</kbd> {{ t('search.close') }}</span>
|
||||
</span>
|
||||
<a
|
||||
v-if="trimmed"
|
||||
class="foot-all"
|
||||
href="#"
|
||||
@click.prevent="openAllResults"
|
||||
>
|
||||
{{ t('search.allResults', { q: trimmed }) }}
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user