mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +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';
|
||||
@@ -76,7 +76,23 @@
|
||||
}
|
||||
},
|
||||
"side_panel": {
|
||||
"search_placeholder": "Search Friend",
|
||||
"search_placeholder": "Search...",
|
||||
"search_no_results": "No results found",
|
||||
"search_min_chars": "Type at least 2 characters",
|
||||
"search_categories": "Search for...",
|
||||
"search_scope_all": "Name, memo & note",
|
||||
"search_scope_own": "Own & favorites",
|
||||
"search_scope_joined": "Own & joined",
|
||||
"search_friends": "Friends",
|
||||
"search_avatars": "Avatars",
|
||||
"search_own_avatars": "Own Avatars",
|
||||
"search_fav_avatars": "Favorite Avatars",
|
||||
"search_worlds": "Worlds",
|
||||
"search_own_worlds": "Own Worlds",
|
||||
"search_fav_worlds": "Favorite Worlds",
|
||||
"search_groups": "Groups",
|
||||
"search_own_groups": "Own Groups",
|
||||
"search_joined_groups": "Joined Groups",
|
||||
"search_result_active": "Offline",
|
||||
"search_result_offline": "Active",
|
||||
"search_result_more": "Search More:",
|
||||
|
||||
352
src/shared/utils/__tests__/globalSearchUtils.test.js
Normal file
352
src/shared/utils/__tests__/globalSearchUtils.test.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
isPrefixMatch,
|
||||
matchName,
|
||||
searchAvatars,
|
||||
searchFavoriteAvatars,
|
||||
searchFavoriteWorlds,
|
||||
searchFriends,
|
||||
searchGroups,
|
||||
searchWorlds
|
||||
} from '../globalSearchUtils';
|
||||
|
||||
const comparer = new Intl.Collator(undefined, {
|
||||
usage: 'search',
|
||||
sensitivity: 'base'
|
||||
});
|
||||
|
||||
// ── matchName ──────────────────────────────────────────────
|
||||
|
||||
describe('matchName', () => {
|
||||
test('matches substring', () => {
|
||||
expect(matchName('HelloWorld', 'llo', comparer)).toBe(true);
|
||||
});
|
||||
|
||||
test('case-insensitive', () => {
|
||||
expect(matchName('Alice', 'alice', comparer)).toBe(true);
|
||||
});
|
||||
|
||||
test('strips whitespace from query', () => {
|
||||
expect(matchName('Alice', 'al ice', comparer)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for empty inputs', () => {
|
||||
expect(matchName('', 'query', comparer)).toBe(false);
|
||||
expect(matchName('name', '', comparer)).toBe(false);
|
||||
expect(matchName(null, 'query', comparer)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for whitespace-only query', () => {
|
||||
expect(matchName('Alice', ' ', comparer)).toBe(false);
|
||||
});
|
||||
|
||||
test('no match', () => {
|
||||
expect(matchName('Alice', 'bob', comparer)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPrefixMatch ──────────────────────────────────────────
|
||||
|
||||
describe('isPrefixMatch', () => {
|
||||
test('detects prefix', () => {
|
||||
expect(isPrefixMatch('Alice', 'ali', comparer)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects non-prefix substring', () => {
|
||||
expect(isPrefixMatch('Alice', 'ice', comparer)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty inputs', () => {
|
||||
expect(isPrefixMatch('', 'a', comparer)).toBe(false);
|
||||
expect(isPrefixMatch('Alice', '', comparer)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchFriends ──────────────────────────────────────────
|
||||
|
||||
describe('searchFriends', () => {
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
* @param name
|
||||
* @param memo
|
||||
* @param note
|
||||
*/
|
||||
function makeFriend(id, name, memo = '', note = '') {
|
||||
return [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
memo,
|
||||
ref: {
|
||||
currentAvatarThumbnailImageUrl: `img_${id}`,
|
||||
note,
|
||||
$userColour: '#fff'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const friends = new Map([
|
||||
makeFriend('u1', 'Alice'),
|
||||
makeFriend('u2', 'Bob', '同事', ''),
|
||||
makeFriend('u3', 'Charlie', '', 'roommate'),
|
||||
makeFriend('u4', 'Dave')
|
||||
]);
|
||||
|
||||
test('matches by name', () => {
|
||||
const results = searchFriends('alice', friends, comparer);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('u1');
|
||||
expect(results[0].matchedField).toBe('name');
|
||||
});
|
||||
|
||||
test('matches by memo', () => {
|
||||
const results = searchFriends('同事', friends, comparer);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('u2');
|
||||
expect(results[0].matchedField).toBe('memo');
|
||||
expect(results[0].memo).toBe('同事');
|
||||
});
|
||||
|
||||
test('matches by note', () => {
|
||||
const results = searchFriends('roommate', friends, comparer);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('u3');
|
||||
expect(results[0].matchedField).toBe('note');
|
||||
expect(results[0].note).toBe('roommate');
|
||||
});
|
||||
|
||||
test('returns empty for short / empty query', () => {
|
||||
expect(searchFriends('', friends, comparer)).toEqual([]);
|
||||
expect(searchFriends(null, friends, comparer)).toEqual([]);
|
||||
});
|
||||
|
||||
test('respects limit', () => {
|
||||
const many = new Map(
|
||||
Array.from({ length: 20 }, (_, i) =>
|
||||
makeFriend(`u${i}`, `Test${i}`)
|
||||
)
|
||||
);
|
||||
expect(searchFriends('Test', many, comparer, 5)).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('prefix matches sort first', () => {
|
||||
const f = new Map([
|
||||
makeFriend('u1', 'XAliceX'),
|
||||
makeFriend('u2', 'Alice')
|
||||
]);
|
||||
const results = searchFriends('Alice', f, comparer);
|
||||
expect(results[0].id).toBe('u2'); // prefix match first
|
||||
});
|
||||
|
||||
test('skips entries without ref', () => {
|
||||
const broken = new Map([['u1', { id: 'u1', name: 'Test' }]]);
|
||||
expect(searchFriends('Test', broken, comparer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchAvatars ──────────────────────────────────────────
|
||||
|
||||
describe('searchAvatars', () => {
|
||||
const avatarMap = new Map([
|
||||
[
|
||||
'a1',
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Cool Avatar',
|
||||
authorId: 'me',
|
||||
thumbnailImageUrl: 'img1'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a2',
|
||||
{
|
||||
id: 'a2',
|
||||
name: 'Nice Avatar',
|
||||
authorId: 'other',
|
||||
thumbnailImageUrl: 'img2'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a3',
|
||||
{
|
||||
id: 'a3',
|
||||
name: 'Cool Suit',
|
||||
authorId: 'me',
|
||||
thumbnailImageUrl: 'img3'
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
test('finds matching avatars', () => {
|
||||
const results = searchAvatars('Cool', avatarMap, comparer);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('filters by authorId', () => {
|
||||
const results = searchAvatars('Avatar', avatarMap, comparer, 'me');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('a1');
|
||||
});
|
||||
|
||||
test('returns all when authorId is null', () => {
|
||||
const results = searchAvatars('Avatar', avatarMap, comparer, null);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('returns empty for null map', () => {
|
||||
expect(searchAvatars('test', null, comparer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchWorlds ───────────────────────────────────────────
|
||||
|
||||
describe('searchWorlds', () => {
|
||||
const worldMap = new Map([
|
||||
[
|
||||
'w1',
|
||||
{
|
||||
id: 'w1',
|
||||
name: 'Fun World',
|
||||
authorId: 'me',
|
||||
thumbnailImageUrl: 'img1'
|
||||
}
|
||||
],
|
||||
[
|
||||
'w2',
|
||||
{
|
||||
id: 'w2',
|
||||
name: 'Fun Park',
|
||||
authorId: 'other',
|
||||
thumbnailImageUrl: 'img2'
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
test('finds matching worlds', () => {
|
||||
const results = searchWorlds('Fun', worldMap, comparer);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('filters by ownerId (authorId)', () => {
|
||||
const results = searchWorlds('Fun', worldMap, comparer, 'me');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('w1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchGroups ───────────────────────────────────────────
|
||||
|
||||
describe('searchGroups', () => {
|
||||
const groupMap = new Map([
|
||||
[
|
||||
'g1',
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'My Group',
|
||||
ownerId: 'me',
|
||||
iconUrl: 'icon1'
|
||||
}
|
||||
],
|
||||
[
|
||||
'g2',
|
||||
{
|
||||
id: 'g2',
|
||||
name: 'Other Group',
|
||||
ownerId: 'other',
|
||||
iconUrl: 'icon2'
|
||||
}
|
||||
],
|
||||
[
|
||||
'g3',
|
||||
{
|
||||
id: 'g3',
|
||||
name: 'Another My Group',
|
||||
ownerId: 'me',
|
||||
iconUrl: 'icon3'
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
test('finds all matching groups', () => {
|
||||
const results = searchGroups('Group', groupMap, comparer);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('filters by ownerId', () => {
|
||||
const results = searchGroups('Group', groupMap, comparer, 'me');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.id !== 'g2')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns all when ownerId is null', () => {
|
||||
const results = searchGroups('Group', groupMap, comparer, null);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchFavoriteAvatars ──────────────────────────────────
|
||||
|
||||
describe('searchFavoriteAvatars', () => {
|
||||
const favorites = [
|
||||
{
|
||||
name: 'Fav Avatar',
|
||||
ref: { id: 'fa1', name: 'Fav Avatar', thumbnailImageUrl: 'img1' }
|
||||
},
|
||||
{
|
||||
name: 'Cool Fav',
|
||||
ref: { id: 'fa2', name: 'Cool Fav', thumbnailImageUrl: 'img2' }
|
||||
},
|
||||
{ name: 'Broken', ref: null }
|
||||
];
|
||||
|
||||
test('finds matching favorite avatars', () => {
|
||||
const results = searchFavoriteAvatars('Fav', favorites, comparer);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.id)).toContain('fa1');
|
||||
});
|
||||
|
||||
test('skips entries with null ref', () => {
|
||||
const results = searchFavoriteAvatars('Broken', favorites, comparer);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty for null input', () => {
|
||||
expect(searchFavoriteAvatars('test', null, comparer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchFavoriteWorlds ───────────────────────────────────
|
||||
|
||||
describe('searchFavoriteWorlds', () => {
|
||||
const favorites = [
|
||||
{
|
||||
name: 'Fav World',
|
||||
ref: {
|
||||
id: 'fw1',
|
||||
name: 'Fav World',
|
||||
thumbnailImageUrl: 'img1'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Cool Place',
|
||||
ref: {
|
||||
id: 'fw2',
|
||||
name: 'Cool Place',
|
||||
thumbnailImageUrl: 'img2'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
test('finds matching favorite worlds', () => {
|
||||
const results = searchFavoriteWorlds('Cool', favorites, comparer);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('fw2');
|
||||
expect(results[0].type).toBe('world');
|
||||
});
|
||||
|
||||
test('returns empty for empty query', () => {
|
||||
expect(searchFavoriteWorlds('', favorites, comparer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
336
src/shared/utils/globalSearchUtils.js
Normal file
336
src/shared/utils/globalSearchUtils.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { localeIncludes } from './base/string';
|
||||
import removeConfusables, { removeWhitespace } from '../../service/confusables';
|
||||
|
||||
/**
|
||||
* Tests whether a name matches a query using locale-aware comparison.
|
||||
* Handles confusable-character normalization and whitespace stripping.
|
||||
* @param {string} name - The display name to test
|
||||
* @param {string} query - The raw user query (may contain whitespace)
|
||||
* @param {Intl.Collator} comparer - Locale collator for comparison
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchName(name, query, comparer) {
|
||||
if (!name || !query) {
|
||||
return false;
|
||||
}
|
||||
const cleanQuery = removeWhitespace(query);
|
||||
if (!cleanQuery) {
|
||||
return false;
|
||||
}
|
||||
const cleanName = removeConfusables(name);
|
||||
if (localeIncludes(cleanName, cleanQuery, comparer)) {
|
||||
return true;
|
||||
}
|
||||
// Also check raw name for users searching with special characters
|
||||
return localeIncludes(name, cleanQuery, comparer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a query starts the name (for prioritizing prefix matches).
|
||||
* @param {string} name
|
||||
* @param {string} query
|
||||
* @param {Intl.Collator} comparer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPrefixMatch(name, query, comparer) {
|
||||
if (!name || !query) {
|
||||
return false;
|
||||
}
|
||||
const cleanQuery = removeWhitespace(query);
|
||||
if (!cleanQuery) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
comparer.compare(name.substring(0, cleanQuery.length), cleanQuery) === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search friends from the friends Map.
|
||||
* @param {string} query
|
||||
* @param {Map} friends - friendStore.friends Map
|
||||
* @param {Intl.Collator} comparer
|
||||
* @param {number} [limit]
|
||||
* @returns {Array<{id: string, name: string, type: string, imageUrl: string, ref: object}>}
|
||||
*/
|
||||
export function searchFriends(query, friends, comparer, limit = 10) {
|
||||
if (!query || !friends) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
for (const ctx of friends.values()) {
|
||||
if (typeof ctx.ref === 'undefined') {
|
||||
continue;
|
||||
}
|
||||
let match = matchName(ctx.name, query, comparer);
|
||||
let matchedField = match ? 'name' : null;
|
||||
// Include memo and note matching for friends (with raw query for spaces)
|
||||
if (!match && ctx.memo) {
|
||||
match = localeIncludes(ctx.memo, query, comparer);
|
||||
if (match) matchedField = 'memo';
|
||||
}
|
||||
if (!match && ctx.ref.note) {
|
||||
match = localeIncludes(ctx.ref.note, query, comparer);
|
||||
if (match) matchedField = 'note';
|
||||
}
|
||||
if (match) {
|
||||
results.push({
|
||||
id: ctx.id,
|
||||
name: ctx.name,
|
||||
type: 'friend',
|
||||
imageUrl: ctx.ref.currentAvatarThumbnailImageUrl,
|
||||
memo: ctx.memo || '',
|
||||
note: ctx.ref.note || '',
|
||||
matchedField,
|
||||
ref: ctx.ref
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort: prefix matches first, then alphabetically
|
||||
results.sort((a, b) => {
|
||||
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||
if (aPrefix && !bPrefix) return -1;
|
||||
if (bPrefix && !aPrefix) return 1;
|
||||
return comparer.compare(a.name, b.name);
|
||||
});
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search avatars from a Map (cachedAvatars or favorite avatars).
|
||||
* @param {string} query
|
||||
* @param {Map} avatarMap
|
||||
* @param {Intl.Collator} comparer
|
||||
* @param {string|null} [authorId] - If provided, only match avatars by this author
|
||||
* @param {number} [limit]
|
||||
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
|
||||
*/
|
||||
export function searchAvatars(
|
||||
query,
|
||||
avatarMap,
|
||||
comparer,
|
||||
authorId = null,
|
||||
limit = 10
|
||||
) {
|
||||
if (!query || !avatarMap) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
for (const ref of avatarMap.values()) {
|
||||
if (!ref || !ref.name) {
|
||||
continue;
|
||||
}
|
||||
if (authorId && ref.authorId !== authorId) {
|
||||
continue;
|
||||
}
|
||||
if (matchName(ref.name, query, comparer)) {
|
||||
results.push({
|
||||
id: ref.id,
|
||||
name: ref.name,
|
||||
type: 'avatar',
|
||||
imageUrl: ref.thumbnailImageUrl || ref.imageUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||
if (aPrefix && !bPrefix) return -1;
|
||||
if (bPrefix && !aPrefix) return 1;
|
||||
return comparer.compare(a.name, b.name);
|
||||
});
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search worlds from a Map (cachedWorlds or favorite worlds).
|
||||
* @param {string} query
|
||||
* @param {Map} worldMap
|
||||
* @param {Intl.Collator} comparer
|
||||
* @param {string|null} [ownerId] - If provided, only match worlds owned by this user
|
||||
* @param {number} [limit]
|
||||
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
|
||||
*/
|
||||
export function searchWorlds(
|
||||
query,
|
||||
worldMap,
|
||||
comparer,
|
||||
ownerId = null,
|
||||
limit = 10
|
||||
) {
|
||||
if (!query || !worldMap) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
for (const ref of worldMap.values()) {
|
||||
if (!ref || !ref.name) {
|
||||
continue;
|
||||
}
|
||||
if (ownerId && ref.authorId !== ownerId) {
|
||||
continue;
|
||||
}
|
||||
if (matchName(ref.name, query, comparer)) {
|
||||
results.push({
|
||||
id: ref.id,
|
||||
name: ref.name,
|
||||
type: 'world',
|
||||
imageUrl: ref.thumbnailImageUrl || ref.imageUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||
if (aPrefix && !bPrefix) return -1;
|
||||
if (bPrefix && !aPrefix) return 1;
|
||||
return comparer.compare(a.name, b.name);
|
||||
});
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search groups from a Map (currentUserGroups).
|
||||
* @param {string} query
|
||||
* @param {Map} groupMap
|
||||
* @param {Intl.Collator} comparer
|
||||
* @param {string|null} [ownerId] - If provided, only match groups owned by this user
|
||||
* @param {number} [limit]
|
||||
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
|
||||
*/
|
||||
export function searchGroups(
|
||||
query,
|
||||
groupMap,
|
||||
comparer,
|
||||
ownerId = null,
|
||||
limit = 10
|
||||
) {
|
||||
if (!query || !groupMap) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
for (const ref of groupMap.values()) {
|
||||
if (!ref || !ref.name) {
|
||||
continue;
|
||||
}
|
||||
if (ownerId && ref.ownerId !== ownerId) {
|
||||
continue;
|
||||
}
|
||||
if (matchName(ref.name, query, comparer)) {
|
||||
results.push({
|
||||
id: ref.id,
|
||||
name: ref.name,
|
||||
type: 'group',
|
||||
imageUrl: ref.iconUrl || ref.bannerUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||
if (aPrefix && !bPrefix) return -1;
|
||||
if (bPrefix && !aPrefix) return 1;
|
||||
return comparer.compare(a.name, b.name);
|
||||
});
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search favorite avatars from the favoriteStore array.
|
||||
* @param {string} query
|
||||
* @param {Array} favoriteAvatars - favoriteStore.favoriteAvatars array of { name, ref }
|
||||
* @param {Intl.Collator} comparer
|
||||
* @param {number} [limit]
|
||||
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
|
||||
*/
|
||||
export function searchFavoriteAvatars(
|
||||
query,
|
||||
favoriteAvatars,
|
||||
comparer,
|
||||
limit = 10
|
||||
) {
|
||||
if (!query || !favoriteAvatars) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
for (const ctx of favoriteAvatars) {
|
||||
if (!ctx?.ref?.name) {
|
||||
continue;
|
||||
}
|
||||
if (matchName(ctx.ref.name, query, comparer)) {
|
||||
results.push({
|
||||
id: ctx.ref.id,
|
||||
name: ctx.ref.name,
|
||||
type: 'avatar',
|
||||
imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||
if (aPrefix && !bPrefix) return -1;
|
||||
if (bPrefix && !aPrefix) return 1;
|
||||
return comparer.compare(a.name, b.name);
|
||||
});
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search favorite worlds from the favoriteStore array.
|
||||
* @param {string} query
|
||||
* @param {Array} favoriteWorlds - favoriteStore.favoriteWorlds array of { name, ref }
|
||||
* @param {Intl.Collator} comparer
|
||||
* @param {number} [limit]
|
||||
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
|
||||
*/
|
||||
export function searchFavoriteWorlds(
|
||||
query,
|
||||
favoriteWorlds,
|
||||
comparer,
|
||||
limit = 10
|
||||
) {
|
||||
if (!query || !favoriteWorlds) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
for (const ctx of favoriteWorlds) {
|
||||
if (!ctx?.ref?.name) {
|
||||
continue;
|
||||
}
|
||||
if (matchName(ctx.ref.name, query, comparer)) {
|
||||
results.push({
|
||||
id: ctx.ref.id,
|
||||
name: ctx.ref.name,
|
||||
type: 'world',
|
||||
imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||
if (aPrefix && !bPrefix) return -1;
|
||||
if (bPrefix && !aPrefix) return 1;
|
||||
return comparer.compare(a.name, b.name);
|
||||
});
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { avatarRequest, miscRequest } from '../api';
|
||||
import { AppDebug } from '../service/appConfig';
|
||||
import { database } from '../service/database';
|
||||
import { processBulk } from '../service/request';
|
||||
import { useAdvancedSettingsStore } from './settings/advanced';
|
||||
import { useAvatarProviderStore } from './avatarProvider';
|
||||
import { useFavoriteStore } from './favorite';
|
||||
@@ -79,15 +80,16 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
avatarHistory.value = [];
|
||||
if (isLoggedIn) {
|
||||
getAvatarHistory();
|
||||
preloadOwnAvatars();
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
/**
|
||||
/ * @param {object} json
|
||||
/ * @returns {object} ref
|
||||
*/
|
||||
* @param {object} json
|
||||
* @returns {object} ref
|
||||
*/
|
||||
function applyAvatar(json) {
|
||||
json.name = replaceBioSymbols(json.name);
|
||||
json.description = replaceBioSymbols(json.description);
|
||||
@@ -332,6 +334,9 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateVRChatAvatarCache() {
|
||||
const D = avatarDialog.value;
|
||||
if (D.visible) {
|
||||
@@ -398,11 +403,17 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function clearAvatarHistory() {
|
||||
avatarHistory.value = [];
|
||||
database.clearAvatarHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function promptClearAvatarHistory() {
|
||||
modalStore
|
||||
.confirm({
|
||||
@@ -444,6 +455,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type
|
||||
* @param search
|
||||
*/
|
||||
async function lookupAvatars(type, search) {
|
||||
const avatars = new Map();
|
||||
if (type === 'search') {
|
||||
@@ -507,6 +523,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
return avatars;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param authorId
|
||||
* @param fileId
|
||||
*/
|
||||
async function lookupAvatarByImageFileId(authorId, fileId) {
|
||||
for (const providerUrl of avatarProviderStore.avatarRemoteDatabaseProviderList) {
|
||||
const avatar = await lookupAvatarByFileId(providerUrl, fileId);
|
||||
@@ -529,6 +550,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param providerUrl
|
||||
* @param fileId
|
||||
*/
|
||||
async function lookupAvatarByFileId(providerUrl, fileId) {
|
||||
try {
|
||||
const url = `${providerUrl}?fileId=${encodeURIComponent(fileId)}`;
|
||||
@@ -568,6 +594,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param providerUrl
|
||||
* @param authorId
|
||||
*/
|
||||
async function lookupAvatarsByAuthor(providerUrl, authorId) {
|
||||
const avatars = [];
|
||||
if (!providerUrl || !authorId) {
|
||||
@@ -615,6 +646,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
return avatars;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
function selectAvatarWithConfirmation(id) {
|
||||
modalStore
|
||||
.confirm({
|
||||
@@ -628,6 +663,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
async function selectAvatarWithoutConfirmation(id) {
|
||||
if (userStore.currentUser.currentAvatar === id) {
|
||||
toast.info('Avatar already selected');
|
||||
@@ -642,6 +681,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fileId
|
||||
*/
|
||||
function checkAvatarCache(fileId) {
|
||||
let avatarId = '';
|
||||
for (let ref of cachedAvatars.values()) {
|
||||
@@ -652,6 +695,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
return avatarId;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fileId
|
||||
* @param ownerUserId
|
||||
*/
|
||||
async function checkAvatarCacheRemote(fileId, ownerUserId) {
|
||||
if (advancedSettingsStore.avatarRemoteDatabase) {
|
||||
try {
|
||||
@@ -673,6 +721,12 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param refUserId
|
||||
* @param ownerUserId
|
||||
* @param currentAvatarImageUrl
|
||||
*/
|
||||
async function showAvatarAuthorDialog(
|
||||
refUserId,
|
||||
ownerUserId,
|
||||
@@ -712,6 +766,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param avatarId
|
||||
*/
|
||||
function addAvatarWearTime(avatarId) {
|
||||
if (!userStore.currentUser.$previousAvatarSwapTime || !avatarId) {
|
||||
return;
|
||||
@@ -721,6 +779,30 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
database.addAvatarTimeSpent(avatarId, timeSpent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all own avatars into cache at startup for global search.
|
||||
*/
|
||||
async function preloadOwnAvatars() {
|
||||
const params = {
|
||||
n: 50,
|
||||
offset: 0,
|
||||
sort: 'updated',
|
||||
order: 'descending',
|
||||
releaseStatus: 'all',
|
||||
user: 'me'
|
||||
};
|
||||
await processBulk({
|
||||
fn: avatarRequest.getAvatars,
|
||||
N: -1,
|
||||
params,
|
||||
handle: (args) => {
|
||||
for (const json of args.json) {
|
||||
applyAvatar(json);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
avatarDialog,
|
||||
avatarHistory,
|
||||
@@ -741,6 +823,7 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
||||
selectAvatarWithConfirmation,
|
||||
selectAvatarWithoutConfirmation,
|
||||
showAvatarAuthorDialog,
|
||||
addAvatarWearTime
|
||||
addAvatarWearTime,
|
||||
preloadOwnAvatars
|
||||
};
|
||||
});
|
||||
|
||||
192
src/stores/globalSearch.js
Normal file
192
src/stores/globalSearch.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import {
|
||||
searchAvatars,
|
||||
searchFavoriteAvatars,
|
||||
searchFavoriteWorlds,
|
||||
searchFriends,
|
||||
searchGroups,
|
||||
searchWorlds
|
||||
} from '../shared/utils/globalSearchUtils';
|
||||
import { useAvatarStore } from './avatar';
|
||||
import { useFavoriteStore } from './favorite';
|
||||
import { useFriendStore } from './friend';
|
||||
import { useGroupStore } from './group';
|
||||
import { useUserStore } from './user';
|
||||
import { useWorldStore } from './world';
|
||||
|
||||
export const useGlobalSearchStore = defineStore('GlobalSearch', () => {
|
||||
const friendStore = useFriendStore();
|
||||
const favoriteStore = useFavoriteStore();
|
||||
const avatarStore = useAvatarStore();
|
||||
const worldStore = useWorldStore();
|
||||
const groupStore = useGroupStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const query = ref('');
|
||||
|
||||
const stringComparer = computed(
|
||||
() =>
|
||||
new Intl.Collator(undefined, {
|
||||
usage: 'search',
|
||||
sensitivity: 'base'
|
||||
})
|
||||
);
|
||||
|
||||
// Reset query when dialog closes
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const currentUserId = computed(() => userStore.currentUser?.id);
|
||||
|
||||
const friendResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
return searchFriends(
|
||||
query.value,
|
||||
friendStore.friends,
|
||||
stringComparer.value
|
||||
);
|
||||
});
|
||||
|
||||
// Own avatars (filter cachedAvatars by authorId)
|
||||
const ownAvatarResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
return searchAvatars(
|
||||
query.value,
|
||||
avatarStore.cachedAvatars,
|
||||
stringComparer.value,
|
||||
currentUserId.value
|
||||
);
|
||||
});
|
||||
|
||||
// Favorite avatars (from favoriteStore, deduplicated against own)
|
||||
const favoriteAvatarResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
const favResults = searchFavoriteAvatars(
|
||||
query.value,
|
||||
favoriteStore.favoriteAvatars,
|
||||
stringComparer.value
|
||||
);
|
||||
// Deduplicate: remove items already in ownAvatarResults
|
||||
const ownIds = new Set(ownAvatarResults.value.map((r) => r.id));
|
||||
return favResults.filter((r) => !ownIds.has(r.id));
|
||||
});
|
||||
|
||||
// Own worlds (filter cachedWorlds by authorId)
|
||||
const ownWorldResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
return searchWorlds(
|
||||
query.value,
|
||||
worldStore.cachedWorlds,
|
||||
stringComparer.value,
|
||||
currentUserId.value
|
||||
);
|
||||
});
|
||||
|
||||
// Favorite worlds (from favoriteStore, deduplicated against own)
|
||||
const favoriteWorldResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
const favResults = searchFavoriteWorlds(
|
||||
query.value,
|
||||
favoriteStore.favoriteWorlds,
|
||||
stringComparer.value
|
||||
);
|
||||
// Deduplicate: remove items already in ownWorldResults
|
||||
const ownIds = new Set(ownWorldResults.value.map((r) => r.id));
|
||||
return favResults.filter((r) => !ownIds.has(r.id));
|
||||
});
|
||||
|
||||
// Own groups (filter by ownerId === currentUser)
|
||||
const ownGroupResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
return searchGroups(
|
||||
query.value,
|
||||
groupStore.currentUserGroups,
|
||||
stringComparer.value,
|
||||
currentUserId.value
|
||||
);
|
||||
});
|
||||
|
||||
// Joined groups (all matching groups, deduplicated against own)
|
||||
const joinedGroupResults = computed(() => {
|
||||
if (!query.value || query.value.length < 2) return [];
|
||||
const allResults = searchGroups(
|
||||
query.value,
|
||||
groupStore.currentUserGroups,
|
||||
stringComparer.value
|
||||
);
|
||||
const ownIds = new Set(ownGroupResults.value.map((r) => r.id));
|
||||
return allResults.filter((r) => !ownIds.has(r.id));
|
||||
});
|
||||
|
||||
const hasResults = computed(
|
||||
() =>
|
||||
friendResults.value.length > 0 ||
|
||||
ownAvatarResults.value.length > 0 ||
|
||||
favoriteAvatarResults.value.length > 0 ||
|
||||
ownWorldResults.value.length > 0 ||
|
||||
favoriteWorldResults.value.length > 0 ||
|
||||
ownGroupResults.value.length > 0 ||
|
||||
joinedGroupResults.value.length > 0
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function open() {
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{id: string, type: string}} item
|
||||
*/
|
||||
function selectResult(item) {
|
||||
if (!item) return;
|
||||
|
||||
close();
|
||||
|
||||
switch (item.type) {
|
||||
case 'friend':
|
||||
userStore.showUserDialog(item.id);
|
||||
break;
|
||||
case 'avatar':
|
||||
avatarStore.showAvatarDialog(item.id);
|
||||
break;
|
||||
case 'world':
|
||||
worldStore.showWorldDialog(item.id);
|
||||
break;
|
||||
case 'group':
|
||||
groupStore.showGroupDialog(item.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
query,
|
||||
friendResults,
|
||||
ownAvatarResults,
|
||||
favoriteAvatarResults,
|
||||
ownWorldResults,
|
||||
favoriteWorldResults,
|
||||
ownGroupResults,
|
||||
joinedGroupResults,
|
||||
hasResults,
|
||||
|
||||
open,
|
||||
close,
|
||||
selectResult
|
||||
};
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { useGalleryStore } from './gallery';
|
||||
import { useGameLogStore } from './gameLog';
|
||||
import { useGameStore } from './game';
|
||||
import { useGeneralSettingsStore } from './settings/general';
|
||||
import { useGlobalSearchStore } from './globalSearch';
|
||||
import { useGroupStore } from './group';
|
||||
import { useInstanceStore } from './instance';
|
||||
import { useInviteStore } from './invite';
|
||||
@@ -163,7 +164,8 @@ export function createGlobalStores() {
|
||||
auth: useAuthStore(),
|
||||
vrcStatus: useVrcStatusStore(),
|
||||
charts: useChartsStore(),
|
||||
modal: useModalStore()
|
||||
modal: useModalStore(),
|
||||
globalSearch: useGlobalSearchStore()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,5 +204,6 @@ export {
|
||||
useSharedFeedStore,
|
||||
useUpdateLoopStore,
|
||||
useVrcStatusStore,
|
||||
useModalStore
|
||||
useModalStore,
|
||||
useGlobalSearchStore
|
||||
};
|
||||
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
} from '../shared/utils';
|
||||
import { instanceRequest, miscRequest, worldRequest } from '../api';
|
||||
import { database } from '../service/database';
|
||||
import { useAvatarStore } from './avatar';
|
||||
import { processBulk } from '../service/request';
|
||||
import { useFavoriteStore } from './favorite';
|
||||
import { useGroupStore } from './group';
|
||||
import { useInstanceStore } from './instance';
|
||||
import { useLocationStore } from './location';
|
||||
import { useUiStore } from './ui';
|
||||
@@ -28,8 +27,6 @@ export const useWorldStore = defineStore('World', () => {
|
||||
const favoriteStore = useFavoriteStore();
|
||||
const instanceStore = useInstanceStore();
|
||||
const userStore = useUserStore();
|
||||
const avatarStore = useAvatarStore();
|
||||
const groupStore = useGroupStore();
|
||||
const uiStore = useUiStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -64,9 +61,12 @@ export const useWorldStore = defineStore('World', () => {
|
||||
|
||||
watch(
|
||||
() => watchState.isLoggedIn,
|
||||
() => {
|
||||
(isLoggedIn) => {
|
||||
worldDialog.visible = false;
|
||||
cachedWorlds.clear();
|
||||
if (isLoggedIn) {
|
||||
preloadOwnWorlds();
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
@@ -210,6 +210,9 @@ export const useWorldStore = defineStore('World', () => {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateVRChatWorldCache() {
|
||||
const D = worldDialog;
|
||||
if (D.visible) {
|
||||
@@ -228,6 +231,10 @@ export const useWorldStore = defineStore('World', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param WorldCache
|
||||
*/
|
||||
function cleanupWorldCache(WorldCache) {
|
||||
const maxCacheSize = 10000;
|
||||
|
||||
@@ -339,11 +346,36 @@ export const useWorldStore = defineStore('World', () => {
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all own worlds into cache at startup for global search.
|
||||
*/
|
||||
async function preloadOwnWorlds() {
|
||||
const params = {
|
||||
n: 50,
|
||||
offset: 0,
|
||||
sort: 'updated',
|
||||
order: 'descending',
|
||||
releaseStatus: 'all',
|
||||
user: 'me'
|
||||
};
|
||||
await processBulk({
|
||||
fn: (p) => worldRequest.getWorlds(p),
|
||||
N: -1,
|
||||
params,
|
||||
handle: (args) => {
|
||||
for (const json of args.json) {
|
||||
applyWorld(json);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
worldDialog,
|
||||
cachedWorlds,
|
||||
showWorldDialog,
|
||||
updateVRChatWorldCache,
|
||||
applyWorld
|
||||
applyWorld,
|
||||
preloadOwnWorlds
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,65 +2,17 @@
|
||||
<div class="x-aside-container">
|
||||
<div style="display: flex; align-items: baseline">
|
||||
<div style="flex: 1; padding: 10px; padding-left: 0">
|
||||
<Popover v-model:open="isQuickSearchOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Input
|
||||
v-model="quickSearchQuery"
|
||||
:placeholder="t('side_panel.search_placeholder')"
|
||||
autocomplete="off" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
class="x-quick-search-popover w-(--reka-popover-trigger-width) p-2"
|
||||
@open-auto-focus.prevent
|
||||
@close-auto-focus.prevent>
|
||||
<div class="max-h-80 overflow-auto">
|
||||
<button
|
||||
v-for="item in quickSearchItems"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="w-full bg-transparent p-0 text-left"
|
||||
@mousedown.prevent
|
||||
@click="handleQuickSearchSelect(item.value)">
|
||||
<div class="x-friend-item">
|
||||
<template v-if="item.ref">
|
||||
<div class="detail">
|
||||
<span class="name" :style="{ color: item.ref.$userColour }">{{
|
||||
item.ref.displayName
|
||||
}}</span>
|
||||
<span v-if="!item.ref.isFriend" class="block truncate text-xs"></span>
|
||||
<span
|
||||
v-else-if="item.ref.state === 'offline'"
|
||||
class="block truncate text-xs"
|
||||
>{{ t('side_panel.search_result_active') }}</span
|
||||
>
|
||||
<span
|
||||
v-else-if="item.ref.state === 'active'"
|
||||
class="block truncate text-xs"
|
||||
>{{ t('side_panel.search_result_offline') }}</span
|
||||
>
|
||||
<Location
|
||||
v-else
|
||||
class="text-xs"
|
||||
:location="item.ref.location"
|
||||
:traveling="item.ref.travelingToLocation"
|
||||
:link="false" />
|
||||
</div>
|
||||
<img :src="userImage(item.ref)" class="avatar" loading="lazy" />
|
||||
</template>
|
||||
<span v-else>
|
||||
{{ t('side_panel.search_result_more') }}
|
||||
<span style="font-weight: bold">{{ item.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="quickSearchItems.length === 0" class="px-2 py-2 text-xs opacity-70">
|
||||
<DataTableEmpty type="nomatch" />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
class="border-input dark:bg-input/30 flex h-9 w-full items-center gap-1 rounded-md border bg-transparent px-3 shadow-xs transition-[color,box-shadow] hover:border-ring cursor-pointer"
|
||||
@click="openGlobalSearch">
|
||||
<Search class="size-4 shrink-0 opacity-50" />
|
||||
<span class="flex-1 text-left text-sm text-muted-foreground truncate">{{
|
||||
t('side_panel.search_placeholder')
|
||||
}}</span>
|
||||
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center mx-1 gap-1">
|
||||
<TooltipWrapper side="bottom" :content="t('side_panel.refresh_tooltip')">
|
||||
@@ -270,6 +222,7 @@
|
||||
</TabsUnderline>
|
||||
<NotificationCenterSheet />
|
||||
<GroupOrderSheet v-model:open="isGroupOrderSheetOpen" />
|
||||
<GlobalSearchDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -283,13 +236,13 @@
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Bell, RefreshCw, Settings } from 'lucide-vue-next';
|
||||
import { Bell, RefreshCw, Search, Settings } from 'lucide-vue-next';
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMagicKeys, whenever } from '@vueuse/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -302,24 +255,37 @@
|
||||
useFavoriteStore,
|
||||
useFriendStore,
|
||||
useGroupStore,
|
||||
useNotificationStore,
|
||||
useSearchStore
|
||||
useNotificationStore
|
||||
} from '../../stores';
|
||||
import { debounce, userImage } from '../../shared/utils';
|
||||
import { useGlobalSearchStore } from '../../stores/globalSearch';
|
||||
|
||||
import FriendsSidebar from './components/FriendsSidebar.vue';
|
||||
import GlobalSearchDialog from '../../components/GlobalSearchDialog.vue';
|
||||
import GroupOrderSheet from './components/GroupOrderSheet.vue';
|
||||
import GroupsSidebar from './components/GroupsSidebar.vue';
|
||||
import NotificationCenterSheet from './components/NotificationCenterSheet.vue';
|
||||
|
||||
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
|
||||
const { refreshFriendsList } = useFriendStore();
|
||||
const { quickSearchRemoteMethod, quickSearchChange } = useSearchStore();
|
||||
const { quickSearchItems } = storeToRefs(useSearchStore());
|
||||
const { groupInstances } = storeToRefs(useGroupStore());
|
||||
const { isNotificationCenterOpen, hasUnseenNotifications } = storeToRefs(useNotificationStore());
|
||||
const globalSearchStore = useGlobalSearchStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
// Keyboard shortcut: Ctrl+K (Windows) / ⌘K (Mac)
|
||||
const keys = useMagicKeys();
|
||||
whenever(keys['Meta+k'], () => openGlobalSearch());
|
||||
whenever(keys['Ctrl+k'], () => openGlobalSearch());
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function openGlobalSearch() {
|
||||
globalSearchStore.open();
|
||||
}
|
||||
|
||||
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||
const {
|
||||
sidebarSortMethod1,
|
||||
@@ -358,6 +324,10 @@
|
||||
return sidebarFavoriteGroups.value;
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleFavoriteGroupsChange(value) {
|
||||
if (!value || value.length === 0) {
|
||||
// Deselected all → reset to all (store as empty)
|
||||
@@ -398,31 +368,6 @@
|
||||
{ value: 'friends', label: t('side_panel.friends') },
|
||||
{ value: 'groups', label: t('side_panel.groups') }
|
||||
]);
|
||||
|
||||
const quickSearchQuery = ref('');
|
||||
const isQuickSearchOpen = ref(false);
|
||||
|
||||
const runQuickSearch = debounce((value) => {
|
||||
quickSearchRemoteMethod(value);
|
||||
}, 200);
|
||||
|
||||
watch(quickSearchQuery, (value) => {
|
||||
const query = String(value ?? '').trim();
|
||||
if (!query) {
|
||||
quickSearchRemoteMethod('');
|
||||
return;
|
||||
}
|
||||
runQuickSearch(query);
|
||||
});
|
||||
|
||||
function handleQuickSearchSelect(value) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
isQuickSearchOpen.value = false;
|
||||
quickSearchQuery.value = '';
|
||||
quickSearchChange(String(value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user