First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View 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>