Files
nocodb/packages/nc-gui/components/cmd-j/index.vue
2026-03-28 07:09:14 +00:00

236 lines
6.4 KiB
Vue

<script setup lang="ts">
import { Client } from 'typesense'
import { useVModel } from '@vueuse/core'
import type { SortedResult } from '#imports'
const props = defineProps<{
open: boolean
setActiveCmdView: (cmd: CommandPaletteType) => void
}>()
const emits = defineEmits(['update:open'])
const vOpen = useVModel(props, 'open', emits)
const { user } = useGlobal()
const modalEl = ref<HTMLElement | null>(null)
const cmdInputEl = ref<HTMLElement | null>(null)
const selectedIndex = ref(0)
const typesenseClient = new Client({
apiKey: 'lNKDTZdJrE76Sg8WEyeN9mXT29l1xq7Q',
nodes: [
{
host: 'rqf5uvajyeczwt3xp-1.a1.typesense.net',
port: 443,
protocol: 'https',
},
],
})
const { search, query } = useTypesenseSearch(typesenseClient, 'noco-docs-v2')
const hide = () => {
vOpen.value = false
search.value = ''
selectedIndex.value = 0
}
const navigateToResult = (result: SortedResult) => {
window.open(`https://nocodb.com${result.url}`, '_blank')
hide()
}
const handleKeyDown = (e: KeyboardEvent) => {
if (!vOpen.value || !query.data?.value?.length) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, query.data.value.length - 1)
break
case 'ArrowUp':
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
break
case 'Enter':
e.preventDefault()
if (query.data.value[selectedIndex.value]) {
navigateToResult(query.data.value[selectedIndex.value])
}
break
}
document.querySelector(`.cmdj-action.selected`)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
}
onClickOutside(modalEl, () => {
hide()
})
useEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
hide()
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
hide()
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'l') {
hide()
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
if (vOpen.value || !user?.value?.id) {
hide()
return
}
vOpen.value = true
nextTick(() => {
cmdInputEl.value?.focus()
})
} else {
handleKeyDown(e)
}
})
watch(vOpen, () => {
if (vOpen.value) {
nextTick(() => {
cmdInputEl.value?.focus()
})
} else {
selectedIndex.value = 0
}
})
</script>
<template>
<div v-if="vOpen" class="cmdk-modal cmdj-modal" :class="{ 'cmdk-modal-active cmdj-modal-active': vOpen }">
<div ref="modalEl" class="cmdk-modal-content cmdj-modal-content relative h-[25.25rem]">
<div class="cmdk-input-wrapper border-b-1 border-nc-border-gray-medium">
<GeneralIcon class="h-4 w-4 text-nc-content-gray-muted" icon="search" />
<input ref="cmdInputEl" v-model="search" class="cmdk-input cmdj-input" placeholder="Search through docs" type="text" />
</div>
<div class="cmdk-results-container overflow-y-auto max-h-80">
<div v-if="!search?.length" class="flex flex-col p-4 gap-4 items-center justify-center text-sm">
<img
src="~assets/img/placeholder/no-search-result-found.png"
class="!w-[240px] flex-none"
alt="Search through our documentation"
/>
<div class="text-nc-content-gray-muted">Search through our documentation</div>
</div>
<div
v-else-if="(query.data.value === 'empty' || query.data.value?.length === 0) && !query.isLoading.value"
class="flex flex-col p-4 gap-4 items-center justify-center text-sm"
>
<img
src="~assets/img/placeholder/no-search-result-found.png"
class="!w-[240px] flex-none"
alt="Your search did not match any results"
/>
<div class="text-nc-content-gray-muted">Your search did not match any results</div>
</div>
<div v-else-if="!query.isLoading.value" class="cmdk-results">
<template v-for="(result, index) in query.data.value" :key="result.id">
<div
class="cmdk-action cmdj-action"
:class="{ 'selected': selectedIndex === index, 'pl-4': result.type !== 'page' }"
@click="navigateToResult(result)"
@mouseenter="selectedIndex = index"
>
<div class="cmdk-action-content">
<div v-if="result.type === 'page'" class="pr-4">
<GeneralIcon icon="file" class="h-4 w-4" />
</div>
<div
:class="{
'pl-4': result.type !== 'page',
}"
class="cmdk-action-text flex-1"
>
<div class="cmdk-action-title text-md">{{ result.content }}</div>
</div>
</div>
</div>
</template>
</div>
<div v-else class="flex flex-col p-4 gap-4 justify-center text-sm">
<div>Searching...</div>
</div>
</div>
<CmdFooter active-cmd="cmd-j" :set-active-cmd-view="setActiveCmdView" />
</div>
</div>
</template>
<style lang="scss">
/* TODO Move styles to Windi Classes */
:root {
--cmdk-secondary-background-color: rgb(230, 230, 230);
--cmdk-secondary-text-color: rgb(101, 105, 111);
--cmdk-selected-background: rgb(245, 245, 245);
--cmdk-icon-color: var(--cmdk-secondary-text-color);
--cmdk-icon-size: 1.2em;
--cmdk-modal-background: var(--nc-bg-default);
}
.dark .cmdk-modal {
color: var(--nc-content-gray-subtle);
}
.cmdk-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1100;
color: rgb(60, 65, 73);
font-size: 16px;
.cmdk-action {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
outline: none;
transition: all 0.1s ease;
width: 100%;
font-size: 0.9em;
border-left: 4px solid transparent;
cursor: pointer;
&:hover,
&.selected {
cursor: pointer;
background-color: var(--color-gray-100);
border-left: 4px solid var(--color-brand-400);
outline: none;
}
.cmdk-action-content {
display: flex;
align-items: center;
flex-shrink: 0.01;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.75em 1em;
width: 640px;
min-width: 0;
}
}
}
</style>