main
vue 411 lines 9.6 KB
Raw
1 <template>
2 <n-modal v-model:show="showSearchBox" class="search-box-modal">
3 <n-card content-class="p-0!" class="w-150!" :bordered="false" size="huge" role="dialog" aria-modal="true">
4 <div class="search-box" @keydown.up="prevItem()" @keydown.down="nextItem()">
5 <div class="search-input flex items-center">
6 <Icon :name="SearchIcon" :size="16" />
7 <input v-model="search" placeholder="Search" class="grow" />
8 <n-text code>ESC</n-text>
9 <Icon :name="CloseIcon" :size="20" class="cursor-pointer" @click="closeBox()" />
10 </div>
11 <n-divider />
12 <n-scrollbar ref="scrollContent" class="h-96!">
13 <div class="conten-wrap">
14 <div v-for="group of filteredGroups" :key="group.name" class="group">
15 <div class="group-title">{{ group.name }}</div>
16 <div class="group-list">
17 <button
18 v-for="item of group.items"
19 :id="item.key.toString()"
20 :key="item.key"
21 class="item flex items-center"
22 :class="{ active: item.key === activeItem }"
23 @click="callAction(item.action)"
24 >
25 <div class="icon">
26 <n-avatar
27 v-if="item.iconImage"
28 round
29 :size="28"
30 :src="item.iconImage"
31 :img-props="{ alt: 'avatar' }"
32 />
33 <Icon v-if="item.iconName" :name="item.iconName" :size="16" />
34 </div>
35 <div class="title grow">
36 <Highlighter
37 highlight-class-name="highlight"
38 :search-words="keywords"
39 auto-escape
40 :text-to-highlight="item.title"
41 />
42 </div>
43 <div class="label">{{ item.label }}</div>
44 </button>
45 </div>
46 </div>
47 <div v-if="!filteredGroups.length" class="group-empty">
48 We couldn't find anything matching "{{ search }}"
49 </div>
50 </div>
51 </n-scrollbar>
52 <n-divider />
53 <div class="hint-bar flex items-center justify-center">
54 <div class="hint flex items-center justify-center gap-1">
55 <div class="icon">
56 <Icon :name="ArrowEnterIcon" :size="12" />
57 </div>
58 <span class="label">to select</span>
59 </div>
60 <div class="hint flex items-center justify-center gap-1">
61 <div class="icon">
62 <Icon :name="ArrowSortIcon" :size="12" />
63 </div>
64 <span class="label">to navigate</span>
65 </div>
66 </div>
67 </div>
68 </n-card>
69 </n-modal>
70 </template>
71
72 <script lang="ts" setup>
73 import type { ScrollbarInst } from "naive-ui"
74 import { useMagicKeys, whenever } from "@vueuse/core"
75 import { NAvatar, NCard, NDivider, NModal, NScrollbar, NText } from "naive-ui"
76 import { computed, onMounted, ref } from "vue"
77 import Highlighter from "vue-highlight-words"
78 import Icon from "@/components/common/Icon.vue"
79 import { useFullscreenSwitch } from "@/composables/useFullscreenSwitch"
80 import { useNavigation } from "@/composables/useNavigation"
81 import { useSearchDialog } from "@/composables/useSearchDialog"
82 import { useThemeSwitch } from "@/composables/useThemeSwitch"
83 import { emitter } from "@/emitter"
84 import { getNavigatorOS } from "@/utils"
85
86 interface GroupItem {
87 iconName: string | null
88 iconImage: string | null
89 key: number | string
90 title: string
91 label: string
92 tags?: string[]
93 action: () => void
94 }
95
96 interface Group {
97 name: string
98 items: GroupItem[]
99 }
100
101 type Groups = Group[]
102
103 const SearchIcon = "ion:search-outline"
104 const ArrowEnterIcon = "fluent:arrow-enter-left-24-regular"
105 const ArrowSortIcon = "fluent:arrow-sort-24-regular"
106 const FullScreenIcon = "fluent:full-screen-maximize-24-regular"
107 const DarkModeIcon = "ion:moon-outline"
108 const CloseIcon = "carbon:close"
109 const ConnectorsIcon = "carbon:hybrid-networking"
110 const AlertsIcon = "carbon:warning-hex"
111 const SocAlertsIcon = "carbon:security"
112 const CustomerIcon = "carbon:user-follow"
113
114 const showSearchBox = ref(false)
115 const search = ref("")
116 const activeItem = ref<null | string | number>(null)
117 const commandIcon = ref("")
118 const scrollContent = ref<(ScrollbarInst & { $el: HTMLElement }) | null>(null)
119 const { routeCustomer, routeSocAlerts, routeAlerts, routeConnectors } = useNavigation()
120
121 // TODO-FE: review the groups and items
122 const groups = ref<Groups>([
123 {
124 name: "Applications",
125 items: [
126 {
127 iconName: CustomerIcon,
128 iconImage: null,
129 key: 1,
130 title: "Add a Customer",
131 label: "Shortcut",
132 action() {
133 routeCustomer({ action: "add-customer" }).navigate()
134 emitter.emit("action:add-customer")
135 }
136 },
137 {
138 iconName: ConnectorsIcon,
139 iconImage: null,
140 key: 2,
141 title: "Configure a connector",
142 label: "Shortcut",
143 action() {
144 routeConnectors().navigate()
145 }
146 },
147 {
148 iconName: SocAlertsIcon,
149 iconImage: null,
150 key: 3,
151 title: "View Escalated Alerts",
152 label: "Shortcut",
153 action() {
154 routeSocAlerts().navigate()
155 }
156 },
157 {
158 iconName: AlertsIcon,
159 iconImage: null,
160 key: 4,
161 title: "View Identified Alerts",
162 label: "Shortcut",
163 action() {
164 routeAlerts().navigate()
165 }
166 }
167 ]
168 },
169 {
170 name: "Actions",
171 items: [
172 {
173 iconName: FullScreenIcon,
174 iconImage: null,
175 key: 7,
176 title: "Toggle fullscreen",
177 label: "Action",
178 action() {
179 useFullscreenSwitch().toggle()
180 }
181 },
182 {
183 iconName: DarkModeIcon,
184 iconImage: null,
185 key: 8,
186 title: "Toggle dark mode",
187 label: "Action",
188 action() {
189 useThemeSwitch().toggle()
190 }
191 }
192 ]
193 }
194 ])
195
196 const keywords = computed<string[]>(() => {
197 return search.value.length > 1 ? search.value.split(" ").filter(k => k) : []
198 })
199
200 const filteredGroups = computed<Groups>(() => {
201 if (!keywords.value.length) return groups.value
202
203 return groups.value
204 .map(group => ({
205 name: group.name,
206 items: group.items.filter(
207 item =>
208 keywords.value.some(k => item.title.toLowerCase().includes(k.toLowerCase())) ||
209 item.tags?.some(t => keywords.value.some(k => t.toLowerCase().includes(k.toLowerCase())))
210 )
211 }))
212 .filter(group => group.items.length)
213 })
214
215 const filteredFlattenItems = computed<GroupItem[]>(() => {
216 return filteredGroups.value.reduce((acc, group) => [...acc, ...group.items], [] as GroupItem[])
217 })
218
219 function openBox(e?: MouseEvent) {
220 if (!showSearchBox.value) {
221 showSearchBox.value = true
222
223 setTimeout(() => {
224 search.value = ""
225 activeItem.value = null
226 }, 100)
227 }
228 return e
229 }
230
231 function closeBox() {
232 showSearchBox.value = false
233 search.value = ""
234 activeItem.value = null
235 }
236
237 function callAction(action: () => void) {
238 action()
239 closeBox()
240 }
241
242 function navigateItem(direction: "next" | "prev") {
243 const items = filteredFlattenItems.value
244 if (!items.length) return
245
246 const currentIndex = items.findIndex(item => item.key === activeItem.value)
247 const isAtEnd = currentIndex === items.length - 1
248 const isAtStart = currentIndex === 0
249 const nextItem = items[currentIndex + 1]
250 const prevItem = items[currentIndex - 1]
251 const firstItem = items[0]
252 const lastItem = items.at(-1)
253
254 if (direction === "next") {
255 activeItem.value = (activeItem.value === null || isAtEnd) && firstItem ? firstItem.key : (nextItem?.key ?? null)
256 } else {
257 activeItem.value = (activeItem.value === null || isAtStart) && lastItem ? lastItem.key : (prevItem?.key ?? null)
258 }
259 centerItem()
260 }
261
262 function nextItem() {
263 navigateItem("next")
264 }
265
266 function prevItem() {
267 navigateItem("prev")
268 }
269
270 function performAction() {
271 const item = filteredFlattenItems.value.find(item => item.key === activeItem.value)
272 if (item) {
273 callAction(item.action)
274 }
275 }
276
277 function centerItem() {
278 const element = document.getElementById(activeItem.value?.toString() || "")
279 if (element && scrollContent.value) {
280 element.scrollIntoView({ block: "nearest" })
281 }
282 }
283
284 onMounted(() => {
285 const isWindows = getNavigatorOS() === "Windows"
286 commandIcon.value = isWindows ? "CTRL" : ""
287
288 const keys = useMagicKeys()
289 const ActiveCMD = isWindows ? keys["ctrl+k"] : keys["cmd+k"]
290 const Enter = keys.enter
291
292 useSearchDialog().trigger(openBox)
293
294 if (ActiveCMD) {
295 whenever(ActiveCMD, () => {
296 openBox()
297 })
298 }
299
300 if (Enter) {
301 whenever(Enter, () => {
302 if (showSearchBox.value) {
303 performAction()
304 }
305 })
306 }
307 })
308 </script>
309
310 <style lang="scss" scoped>
311 .search-box-modal {
312 .search-box {
313 border-radius: 4px;
314
315 .search-input {
316 height: 50px;
317 gap: 20px;
318 padding: 20px;
319
320 input {
321 background: transparent;
322 outline: none;
323 border: none;
324 min-width: 100px;
325 }
326
327 .n-text--code {
328 white-space: nowrap;
329 }
330 }
331
332 .n-divider {
333 margin-top: 0;
334 margin-bottom: 0;
335 }
336
337 .conten-wrap {
338 padding-bottom: 30px;
339
340 .group-empty {
341 text-align: center;
342 padding: 30px 0 40px 0;
343 }
344 .group {
345 padding: 0 10px;
346 .group-title {
347 opacity: 0.6;
348 margin-bottom: 5px;
349 padding: 5px 10px;
350 padding-top: 20px;
351 }
352 .group-list {
353 .item {
354 padding: 7px 10px;
355 gap: 10px;
356 cursor: pointer;
357 border-radius: 10px;
358 width: 100%;
359 text-align: left;
360
361 .icon {
362 width: 28px;
363 height: 28px;
364 border-radius: 50%;
365 background-color: rgba(var(--primary-color-rgb) / 0.15);
366 display: flex;
367 justify-content: center;
368 align-items: center;
369 }
370 .title {
371 font-weight: bold;
372 }
373 .label {
374 opacity: 0.8;
375 font-size: 0.9em;
376 }
377
378 &.active {
379 background-color: var(--hover-color);
380 }
381 &:hover {
382 box-shadow: 0px 0px 0px 1px var(--primary-color) inset;
383 }
384 }
385 }
386 }
387 }
388
389 .hint-bar {
390 font-size: 12px;
391 gap: 20px;
392 padding: 10px 0;
393
394 .icon {
395 background-color: rgba(255, 255, 255, 0.3);
396 width: 18px;
397 height: 18px;
398 padding-top: 1px;
399 text-align: center;
400 border-radius: 4px;
401 display: flex;
402 align-items: center;
403 justify-content: center;
404 }
405 .label {
406 opacity: 0.7;
407 }
408 }
409 }
410 }
411 </style>