mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 13:53:52 +02:00
feat: add quick search
This commit is contained in:
266
src/components/GlobalSearchDialog.vue
Normal file
266
src/components/GlobalSearchDialog.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Globe, Image, Users } from 'lucide-vue-next';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useGlobalSearchStore } from '../stores/globalSearch';
|
||||
import { userImage } from '../shared/utils';
|
||||
|
||||
import GlobalSearchSync from './GlobalSearchSync.vue';
|
||||
|
||||
const globalSearchStore = useGlobalSearchStore();
|
||||
const {
|
||||
isOpen,
|
||||
query,
|
||||
friendResults,
|
||||
ownAvatarResults,
|
||||
favoriteAvatarResults,
|
||||
ownWorldResults,
|
||||
favoriteWorldResults,
|
||||
ownGroupResults,
|
||||
joinedGroupResults,
|
||||
hasResults
|
||||
} = storeToRefs(globalSearchStore);
|
||||
const { selectResult } = globalSearchStore;
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* @param item
|
||||
*/
|
||||
function handleSelect(item) {
|
||||
selectResult(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="overflow-hidden p-0 sm:max-w-2xl" :show-close-button="false">
|
||||
<DialogHeader class="sr-only">
|
||||
<DialogTitle>{{ t('side_panel.search_placeholder') }}</DialogTitle>
|
||||
<DialogDescription>{{ t('side_panel.search_placeholder') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Command>
|
||||
<!-- Sync filterState.search → store.query -->
|
||||
<GlobalSearchSync />
|
||||
<CommandInput :placeholder="t('side_panel.search_placeholder')" />
|
||||
<CommandList class="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
|
||||
<template v-if="!query || query.length < 2">
|
||||
<CommandGroup :heading="t('side_panel.search_categories')">
|
||||
<CommandItem :value="'hint-friends'" disabled class="gap-3 opacity-70">
|
||||
<Users class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_friends') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_all')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
<CommandItem :value="'hint-avatars'" disabled class="gap-3 opacity-70">
|
||||
<Image class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_avatars') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_own')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
<CommandItem :value="'hint-worlds'" disabled class="gap-3 opacity-70">
|
||||
<Globe class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_worlds') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_own')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
<CommandItem :value="'hint-groups'" disabled class="gap-3 opacity-70">
|
||||
<Users class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_groups') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_joined')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!hasResults" class="py-6 text-center text-sm text-muted-foreground">
|
||||
{{ t('side_panel.search_no_results') }}
|
||||
</div>
|
||||
|
||||
<CommandGroup v-if="friendResults.length > 0" :heading="t('side_panel.friends')">
|
||||
<CommandItem
|
||||
v-for="item in friendResults"
|
||||
:key="item.id"
|
||||
:value="[item.name, item.memo, item.note, item.id].filter(Boolean).join(' ')"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.ref"
|
||||
:src="userImage(item.ref)"
|
||||
class="size-6 rounded-full object-cover"
|
||||
loading="lazy" />
|
||||
<Users v-else class="size-4" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="truncate" :style="{ color: item.ref?.$userColour }">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.matchedField !== 'name' && item.memo"
|
||||
class="truncate text-xs text-muted-foreground">
|
||||
Memo: {{ item.memo }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.matchedField !== 'name' && item.note"
|
||||
class="truncate text-xs text-muted-foreground">
|
||||
Note: {{ item.note }}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="ownAvatarResults.length > 0" :heading="t('side_panel.search_own_avatars')">
|
||||
<CommandItem
|
||||
v-for="item in ownAvatarResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' own ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Image v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup
|
||||
v-if="favoriteAvatarResults.length > 0"
|
||||
:heading="t('side_panel.search_fav_avatars')">
|
||||
<CommandItem
|
||||
v-for="item in favoriteAvatarResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' fav ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Image v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="ownWorldResults.length > 0" :heading="t('side_panel.search_own_worlds')">
|
||||
<CommandItem
|
||||
v-for="item in ownWorldResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' own ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Globe v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup
|
||||
v-if="favoriteWorldResults.length > 0"
|
||||
:heading="t('side_panel.search_fav_worlds')">
|
||||
<CommandItem
|
||||
v-for="item in favoriteWorldResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' fav ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Globe v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="ownGroupResults.length > 0" :heading="t('side_panel.search_own_groups')">
|
||||
<CommandItem
|
||||
v-for="item in ownGroupResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' own ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Users v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup
|
||||
v-if="joinedGroupResults.length > 0"
|
||||
:heading="t('side_panel.search_joined_groups')">
|
||||
<CommandItem
|
||||
v-for="item in joinedGroupResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' joined ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Users v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</template>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scale up the entire Command UI */
|
||||
|
||||
/* Taller input wrapper */
|
||||
:deep([data-slot='command-input-wrapper']) {
|
||||
height: 3rem; /* h-12 */
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
/* Larger input text */
|
||||
:deep([data-slot='command-input']) {
|
||||
font-size: 0.9375rem; /* ~15px */
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
/* Larger search icon in input */
|
||||
:deep([data-slot='command-input-wrapper'] > .lucide-search) {
|
||||
width: 1.25rem; /* size-5 */
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Bigger list items */
|
||||
:deep([data-slot='command-item']) {
|
||||
font-size: 0.9375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Bigger group headings */
|
||||
:deep([data-slot='command-group-heading']) {
|
||||
font-size: 0.8125rem; /* ~13px */
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
36
src/components/GlobalSearchSync.vue
Normal file
36
src/components/GlobalSearchSync.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Renderless bridge component — must live inside the Command context.
|
||||
* Watches filterState.search (set by CommandInput) and syncs it
|
||||
* to the global search store's query ref.
|
||||
* Also overrides the built-in filter when query < 2 chars so that
|
||||
* hint category items remain visible.
|
||||
*/
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { useCommand } from '@/components/ui/command';
|
||||
|
||||
import { useGlobalSearchStore } from '../stores/globalSearch';
|
||||
|
||||
const { filterState, allItems, allGroups } = useCommand();
|
||||
const globalSearchStore = useGlobalSearchStore();
|
||||
|
||||
watch(
|
||||
() => filterState.search,
|
||||
async (value) => {
|
||||
globalSearchStore.query = value;
|
||||
|
||||
// When query < 2 chars, override the built-in filter
|
||||
// so all items (hint categories) stay visible
|
||||
if (value && value.length < 2) {
|
||||
await nextTick();
|
||||
for (const id of allItems.value.keys()) {
|
||||
filterState.filtered.items.set(id, 1);
|
||||
}
|
||||
filterState.filtered.count = allItems.value.size;
|
||||
for (const groupId of allGroups.value.keys()) {
|
||||
filterState.filtered.groups.add(groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
25
src/components/ui/kbd/Kbd.vue
Normal file
25
src/components/ui/kbd/Kbd.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
class: {
|
||||
type: [Boolean, null, String, Object, Array],
|
||||
required: false,
|
||||
skipCheck: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||
'[&_svg:not([class*=\'size-\'])]:size-3',
|
||||
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
17
src/components/ui/kbd/KbdGroup.vue
Normal file
17
src/components/ui/kbd/KbdGroup.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
class: {
|
||||
type: [Boolean, null, String, Object, Array],
|
||||
required: false,
|
||||
skipCheck: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd data-slot="kbd-group" :class="cn('inline-flex items-center gap-1', props.class)">
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
2
src/components/ui/kbd/index.js
Normal file
2
src/components/ui/kbd/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Kbd } from './Kbd.vue';
|
||||
export { default as KbdGroup } from './KbdGroup.vue';
|
||||
Reference in New Issue
Block a user