104 lines
3.6 KiB
Vue
104 lines
3.6 KiB
Vue
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Search, Folder, ListTodo, TicketCheck, Bot, CornerDownLeft } from 'lucide-vue-next'
|
|
import { call } from '../api'
|
|
import { useAppStore } from '../store'
|
|
|
|
const store = useAppStore()
|
|
const router = useRouter()
|
|
const { t } = useI18n()
|
|
const q = ref('')
|
|
const hits = ref([])
|
|
const sel = ref(0)
|
|
const busy = ref(false)
|
|
const searched = ref(false)
|
|
const input = ref(null)
|
|
const listEl = ref(null)
|
|
let timer = null
|
|
|
|
const kindMeta = {
|
|
project: { icon: Folder, label: 'kindProject' },
|
|
todo: { icon: ListTodo, label: 'kindTodo' },
|
|
ticket: { icon: TicketCheck, label: 'kindTicket' },
|
|
conversation: { icon: Bot, label: 'kindConversation' }
|
|
}
|
|
const groups = computed(() => {
|
|
const by = {}
|
|
const g = []
|
|
for (const h of hits.value) {
|
|
if (!by[h.kind]) { by[h.kind] = { kind: h.kind, items: [] }; g.push(by[h.kind]) }
|
|
by[h.kind].items.push(h)
|
|
}
|
|
return g
|
|
})
|
|
|
|
watch(q, () => {
|
|
clearTimeout(timer)
|
|
timer = setTimeout(search, 180)
|
|
})
|
|
async function search() {
|
|
const s = q.value.trim()
|
|
if (!s) { hits.value = []; sel.value = 0; searched.value = false; return }
|
|
busy.value = true
|
|
try {
|
|
hits.value = await call('GlobalSearch', s) || []
|
|
sel.value = 0
|
|
searched.value = true
|
|
} catch { hits.value = [] }
|
|
busy.value = false
|
|
}
|
|
function close() { store.paletteOpen = false }
|
|
function go(h) {
|
|
close()
|
|
if (h.kind === 'project') router.push(`/project/${h.id}`)
|
|
else if (h.kind === 'todo') router.push('/todos')
|
|
else if (h.kind === 'ticket') router.push('/tickets')
|
|
else router.push({ path: '/ai', query: { conversation: h.id } })
|
|
}
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
|
|
else if (e.key === 'Enter' && hits.value[sel.value]) { e.preventDefault(); go(hits.value[sel.value]) }
|
|
}
|
|
function move(d) {
|
|
if (!hits.value.length) return
|
|
sel.value = (sel.value + d + hits.value.length) % hits.value.length
|
|
nextTick(() => listEl.value?.querySelector('.cp-item.active')?.scrollIntoView({ block: 'nearest' }))
|
|
}
|
|
const flatIndex = h => hits.value.indexOf(h)
|
|
onMounted(() => {
|
|
addEventListener('keydown', onKey)
|
|
nextTick(() => input.value?.focus())
|
|
})
|
|
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="cp-overlay" @click.self="close">
|
|
<section class="cp-panel popover-glass">
|
|
<label class="cp-input">
|
|
<Search />
|
|
<input ref="input" v-model="q" :placeholder="t('searchPlaceholder')" spellcheck="false" />
|
|
<kbd>Esc</kbd>
|
|
</label>
|
|
<div v-if="groups.length" ref="listEl" class="cp-list">
|
|
<div v-for="g in groups" :key="g.kind" class="cp-group">
|
|
<small>{{ t(kindMeta[g.kind].label) }}</small>
|
|
<button v-for="h in g.items" :key="g.kind + h.id" class="cp-item" :class="{ active: flatIndex(h) === sel }"
|
|
@mouseenter="sel = flatIndex(h)" @click="go(h)">
|
|
<component :is="kindMeta[g.kind].icon" />
|
|
<span class="cp-title">{{ h.title || '—' }}</span>
|
|
<span v-if="h.sub" class="cp-sub">{{ h.sub }}</span>
|
|
<CornerDownLeft v-if="flatIndex(h) === sel" class="cp-enter" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p v-else-if="searched && !busy" class="cp-empty">{{ t('searchEmpty') }}</p>
|
|
<p v-else class="cp-empty muted">{{ t('searchHint') }}</p>
|
|
</section>
|
|
</div>
|
|
</template>
|