285 lines
9.5 KiB
Vue
285 lines
9.5 KiB
Vue
<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>
|