mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-01 04:33:46 +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": {
|
"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_active": "Offline",
|
||||||
"search_result_offline": "Active",
|
"search_result_offline": "Active",
|
||||||
"search_result_more": "Search More:",
|
"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 { avatarRequest, miscRequest } from '../api';
|
||||||
import { AppDebug } from '../service/appConfig';
|
import { AppDebug } from '../service/appConfig';
|
||||||
import { database } from '../service/database';
|
import { database } from '../service/database';
|
||||||
|
import { processBulk } from '../service/request';
|
||||||
import { useAdvancedSettingsStore } from './settings/advanced';
|
import { useAdvancedSettingsStore } from './settings/advanced';
|
||||||
import { useAvatarProviderStore } from './avatarProvider';
|
import { useAvatarProviderStore } from './avatarProvider';
|
||||||
import { useFavoriteStore } from './favorite';
|
import { useFavoriteStore } from './favorite';
|
||||||
@@ -79,14 +80,15 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
avatarHistory.value = [];
|
avatarHistory.value = [];
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
getAvatarHistory();
|
getAvatarHistory();
|
||||||
|
preloadOwnAvatars();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ flush: 'sync' }
|
{ flush: 'sync' }
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
/ * @param {object} json
|
* @param {object} json
|
||||||
/ * @returns {object} ref
|
* @returns {object} ref
|
||||||
*/
|
*/
|
||||||
function applyAvatar(json) {
|
function applyAvatar(json) {
|
||||||
json.name = replaceBioSymbols(json.name);
|
json.name = replaceBioSymbols(json.name);
|
||||||
@@ -332,6 +334,9 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateVRChatAvatarCache() {
|
function updateVRChatAvatarCache() {
|
||||||
const D = avatarDialog.value;
|
const D = avatarDialog.value;
|
||||||
if (D.visible) {
|
if (D.visible) {
|
||||||
@@ -398,11 +403,17 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function clearAvatarHistory() {
|
function clearAvatarHistory() {
|
||||||
avatarHistory.value = [];
|
avatarHistory.value = [];
|
||||||
database.clearAvatarHistory();
|
database.clearAvatarHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function promptClearAvatarHistory() {
|
function promptClearAvatarHistory() {
|
||||||
modalStore
|
modalStore
|
||||||
.confirm({
|
.confirm({
|
||||||
@@ -444,6 +455,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param type
|
||||||
|
* @param search
|
||||||
|
*/
|
||||||
async function lookupAvatars(type, search) {
|
async function lookupAvatars(type, search) {
|
||||||
const avatars = new Map();
|
const avatars = new Map();
|
||||||
if (type === 'search') {
|
if (type === 'search') {
|
||||||
@@ -507,6 +523,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
return avatars;
|
return avatars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param authorId
|
||||||
|
* @param fileId
|
||||||
|
*/
|
||||||
async function lookupAvatarByImageFileId(authorId, fileId) {
|
async function lookupAvatarByImageFileId(authorId, fileId) {
|
||||||
for (const providerUrl of avatarProviderStore.avatarRemoteDatabaseProviderList) {
|
for (const providerUrl of avatarProviderStore.avatarRemoteDatabaseProviderList) {
|
||||||
const avatar = await lookupAvatarByFileId(providerUrl, fileId);
|
const avatar = await lookupAvatarByFileId(providerUrl, fileId);
|
||||||
@@ -529,6 +550,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param providerUrl
|
||||||
|
* @param fileId
|
||||||
|
*/
|
||||||
async function lookupAvatarByFileId(providerUrl, fileId) {
|
async function lookupAvatarByFileId(providerUrl, fileId) {
|
||||||
try {
|
try {
|
||||||
const url = `${providerUrl}?fileId=${encodeURIComponent(fileId)}`;
|
const url = `${providerUrl}?fileId=${encodeURIComponent(fileId)}`;
|
||||||
@@ -568,6 +594,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param providerUrl
|
||||||
|
* @param authorId
|
||||||
|
*/
|
||||||
async function lookupAvatarsByAuthor(providerUrl, authorId) {
|
async function lookupAvatarsByAuthor(providerUrl, authorId) {
|
||||||
const avatars = [];
|
const avatars = [];
|
||||||
if (!providerUrl || !authorId) {
|
if (!providerUrl || !authorId) {
|
||||||
@@ -615,6 +646,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
return avatars;
|
return avatars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
function selectAvatarWithConfirmation(id) {
|
function selectAvatarWithConfirmation(id) {
|
||||||
modalStore
|
modalStore
|
||||||
.confirm({
|
.confirm({
|
||||||
@@ -628,6 +663,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
async function selectAvatarWithoutConfirmation(id) {
|
async function selectAvatarWithoutConfirmation(id) {
|
||||||
if (userStore.currentUser.currentAvatar === id) {
|
if (userStore.currentUser.currentAvatar === id) {
|
||||||
toast.info('Avatar already selected');
|
toast.info('Avatar already selected');
|
||||||
@@ -642,6 +681,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param fileId
|
||||||
|
*/
|
||||||
function checkAvatarCache(fileId) {
|
function checkAvatarCache(fileId) {
|
||||||
let avatarId = '';
|
let avatarId = '';
|
||||||
for (let ref of cachedAvatars.values()) {
|
for (let ref of cachedAvatars.values()) {
|
||||||
@@ -652,6 +695,11 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
return avatarId;
|
return avatarId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param fileId
|
||||||
|
* @param ownerUserId
|
||||||
|
*/
|
||||||
async function checkAvatarCacheRemote(fileId, ownerUserId) {
|
async function checkAvatarCacheRemote(fileId, ownerUserId) {
|
||||||
if (advancedSettingsStore.avatarRemoteDatabase) {
|
if (advancedSettingsStore.avatarRemoteDatabase) {
|
||||||
try {
|
try {
|
||||||
@@ -673,6 +721,12 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param refUserId
|
||||||
|
* @param ownerUserId
|
||||||
|
* @param currentAvatarImageUrl
|
||||||
|
*/
|
||||||
async function showAvatarAuthorDialog(
|
async function showAvatarAuthorDialog(
|
||||||
refUserId,
|
refUserId,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
@@ -712,6 +766,10 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param avatarId
|
||||||
|
*/
|
||||||
function addAvatarWearTime(avatarId) {
|
function addAvatarWearTime(avatarId) {
|
||||||
if (!userStore.currentUser.$previousAvatarSwapTime || !avatarId) {
|
if (!userStore.currentUser.$previousAvatarSwapTime || !avatarId) {
|
||||||
return;
|
return;
|
||||||
@@ -721,6 +779,30 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
database.addAvatarTimeSpent(avatarId, timeSpent);
|
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 {
|
return {
|
||||||
avatarDialog,
|
avatarDialog,
|
||||||
avatarHistory,
|
avatarHistory,
|
||||||
@@ -741,6 +823,7 @@ export const useAvatarStore = defineStore('Avatar', () => {
|
|||||||
selectAvatarWithConfirmation,
|
selectAvatarWithConfirmation,
|
||||||
selectAvatarWithoutConfirmation,
|
selectAvatarWithoutConfirmation,
|
||||||
showAvatarAuthorDialog,
|
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 { useGameLogStore } from './gameLog';
|
||||||
import { useGameStore } from './game';
|
import { useGameStore } from './game';
|
||||||
import { useGeneralSettingsStore } from './settings/general';
|
import { useGeneralSettingsStore } from './settings/general';
|
||||||
|
import { useGlobalSearchStore } from './globalSearch';
|
||||||
import { useGroupStore } from './group';
|
import { useGroupStore } from './group';
|
||||||
import { useInstanceStore } from './instance';
|
import { useInstanceStore } from './instance';
|
||||||
import { useInviteStore } from './invite';
|
import { useInviteStore } from './invite';
|
||||||
@@ -163,7 +164,8 @@ export function createGlobalStores() {
|
|||||||
auth: useAuthStore(),
|
auth: useAuthStore(),
|
||||||
vrcStatus: useVrcStatusStore(),
|
vrcStatus: useVrcStatusStore(),
|
||||||
charts: useChartsStore(),
|
charts: useChartsStore(),
|
||||||
modal: useModalStore()
|
modal: useModalStore(),
|
||||||
|
globalSearch: useGlobalSearchStore()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,5 +204,6 @@ export {
|
|||||||
useSharedFeedStore,
|
useSharedFeedStore,
|
||||||
useUpdateLoopStore,
|
useUpdateLoopStore,
|
||||||
useVrcStatusStore,
|
useVrcStatusStore,
|
||||||
useModalStore
|
useModalStore,
|
||||||
|
useGlobalSearchStore
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import {
|
|||||||
} from '../shared/utils';
|
} from '../shared/utils';
|
||||||
import { instanceRequest, miscRequest, worldRequest } from '../api';
|
import { instanceRequest, miscRequest, worldRequest } from '../api';
|
||||||
import { database } from '../service/database';
|
import { database } from '../service/database';
|
||||||
import { useAvatarStore } from './avatar';
|
import { processBulk } from '../service/request';
|
||||||
import { useFavoriteStore } from './favorite';
|
import { useFavoriteStore } from './favorite';
|
||||||
import { useGroupStore } from './group';
|
|
||||||
import { useInstanceStore } from './instance';
|
import { useInstanceStore } from './instance';
|
||||||
import { useLocationStore } from './location';
|
import { useLocationStore } from './location';
|
||||||
import { useUiStore } from './ui';
|
import { useUiStore } from './ui';
|
||||||
@@ -28,8 +27,6 @@ export const useWorldStore = defineStore('World', () => {
|
|||||||
const favoriteStore = useFavoriteStore();
|
const favoriteStore = useFavoriteStore();
|
||||||
const instanceStore = useInstanceStore();
|
const instanceStore = useInstanceStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const avatarStore = useAvatarStore();
|
|
||||||
const groupStore = useGroupStore();
|
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -64,9 +61,12 @@ export const useWorldStore = defineStore('World', () => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => watchState.isLoggedIn,
|
() => watchState.isLoggedIn,
|
||||||
() => {
|
(isLoggedIn) => {
|
||||||
worldDialog.visible = false;
|
worldDialog.visible = false;
|
||||||
cachedWorlds.clear();
|
cachedWorlds.clear();
|
||||||
|
if (isLoggedIn) {
|
||||||
|
preloadOwnWorlds();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ flush: 'sync' }
|
{ flush: 'sync' }
|
||||||
);
|
);
|
||||||
@@ -210,6 +210,9 @@ export const useWorldStore = defineStore('World', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateVRChatWorldCache() {
|
function updateVRChatWorldCache() {
|
||||||
const D = worldDialog;
|
const D = worldDialog;
|
||||||
if (D.visible) {
|
if (D.visible) {
|
||||||
@@ -228,6 +231,10 @@ export const useWorldStore = defineStore('World', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param WorldCache
|
||||||
|
*/
|
||||||
function cleanupWorldCache(WorldCache) {
|
function cleanupWorldCache(WorldCache) {
|
||||||
const maxCacheSize = 10000;
|
const maxCacheSize = 10000;
|
||||||
|
|
||||||
@@ -339,11 +346,36 @@ export const useWorldStore = defineStore('World', () => {
|
|||||||
return ref;
|
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 {
|
return {
|
||||||
worldDialog,
|
worldDialog,
|
||||||
cachedWorlds,
|
cachedWorlds,
|
||||||
showWorldDialog,
|
showWorldDialog,
|
||||||
updateVRChatWorldCache,
|
updateVRChatWorldCache,
|
||||||
applyWorld
|
applyWorld,
|
||||||
|
preloadOwnWorlds
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,65 +2,17 @@
|
|||||||
<div class="x-aside-container">
|
<div class="x-aside-container">
|
||||||
<div style="display: flex; align-items: baseline">
|
<div style="display: flex; align-items: baseline">
|
||||||
<div style="flex: 1; padding: 10px; padding-left: 0">
|
<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
|
<button
|
||||||
v-for="item in quickSearchItems"
|
|
||||||
:key="item.value"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full bg-transparent p-0 text-left"
|
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"
|
||||||
@mousedown.prevent
|
@click="openGlobalSearch">
|
||||||
@click="handleQuickSearchSelect(item.value)">
|
<Search class="size-4 shrink-0 opacity-50" />
|
||||||
<div class="x-friend-item">
|
<span class="flex-1 text-left text-sm text-muted-foreground truncate">{{
|
||||||
<template v-if="item.ref">
|
t('side_panel.search_placeholder')
|
||||||
<div class="detail">
|
|
||||||
<span class="name" :style="{ color: item.ref.$userColour }">{{
|
|
||||||
item.ref.displayName
|
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-if="!item.ref.isFriend" class="block truncate text-xs"></span>
|
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
|
||||||
<span
|
<Kbd>K</Kbd>
|
||||||
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>
|
</button>
|
||||||
<div v-if="quickSearchItems.length === 0" class="px-2 py-2 text-xs opacity-70">
|
|
||||||
<DataTableEmpty type="nomatch" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mx-1 gap-1">
|
<div class="flex items-center mx-1 gap-1">
|
||||||
<TooltipWrapper side="bottom" :content="t('side_panel.refresh_tooltip')">
|
<TooltipWrapper side="bottom" :content="t('side_panel.refresh_tooltip')">
|
||||||
@@ -270,6 +222,7 @@
|
|||||||
</TabsUnderline>
|
</TabsUnderline>
|
||||||
<NotificationCenterSheet />
|
<NotificationCenterSheet />
|
||||||
<GroupOrderSheet v-model:open="isGroupOrderSheetOpen" />
|
<GroupOrderSheet v-model:open="isGroupOrderSheetOpen" />
|
||||||
|
<GlobalSearchDialog />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -283,13 +236,13 @@
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from '@/components/ui/select';
|
} 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 { Field, FieldContent, FieldLabel } from '@/components/ui/field';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
import { Kbd } from '@/components/ui/kbd';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -302,24 +255,37 @@
|
|||||||
useFavoriteStore,
|
useFavoriteStore,
|
||||||
useFriendStore,
|
useFriendStore,
|
||||||
useGroupStore,
|
useGroupStore,
|
||||||
useNotificationStore,
|
useNotificationStore
|
||||||
useSearchStore
|
|
||||||
} from '../../stores';
|
} from '../../stores';
|
||||||
import { debounce, userImage } from '../../shared/utils';
|
import { useGlobalSearchStore } from '../../stores/globalSearch';
|
||||||
|
|
||||||
import FriendsSidebar from './components/FriendsSidebar.vue';
|
import FriendsSidebar from './components/FriendsSidebar.vue';
|
||||||
|
import GlobalSearchDialog from '../../components/GlobalSearchDialog.vue';
|
||||||
import GroupOrderSheet from './components/GroupOrderSheet.vue';
|
import GroupOrderSheet from './components/GroupOrderSheet.vue';
|
||||||
import GroupsSidebar from './components/GroupsSidebar.vue';
|
import GroupsSidebar from './components/GroupsSidebar.vue';
|
||||||
import NotificationCenterSheet from './components/NotificationCenterSheet.vue';
|
import NotificationCenterSheet from './components/NotificationCenterSheet.vue';
|
||||||
|
|
||||||
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
|
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
|
||||||
const { refreshFriendsList } = useFriendStore();
|
const { refreshFriendsList } = useFriendStore();
|
||||||
const { quickSearchRemoteMethod, quickSearchChange } = useSearchStore();
|
|
||||||
const { quickSearchItems } = storeToRefs(useSearchStore());
|
|
||||||
const { groupInstances } = storeToRefs(useGroupStore());
|
const { groupInstances } = storeToRefs(useGroupStore());
|
||||||
const { isNotificationCenterOpen, hasUnseenNotifications } = storeToRefs(useNotificationStore());
|
const { isNotificationCenterOpen, hasUnseenNotifications } = storeToRefs(useNotificationStore());
|
||||||
|
const globalSearchStore = useGlobalSearchStore();
|
||||||
const { t } = useI18n();
|
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 appearanceSettingsStore = useAppearanceSettingsStore();
|
||||||
const {
|
const {
|
||||||
sidebarSortMethod1,
|
sidebarSortMethod1,
|
||||||
@@ -358,6 +324,10 @@
|
|||||||
return sidebarFavoriteGroups.value;
|
return sidebarFavoriteGroups.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleFavoriteGroupsChange(value) {
|
function handleFavoriteGroupsChange(value) {
|
||||||
if (!value || value.length === 0) {
|
if (!value || value.length === 0) {
|
||||||
// Deselected all → reset to all (store as empty)
|
// Deselected all → reset to all (store as empty)
|
||||||
@@ -398,31 +368,6 @@
|
|||||||
{ value: 'friends', label: t('side_panel.friends') },
|
{ value: 'friends', label: t('side_panel.friends') },
|
||||||
{ value: 'groups', label: t('side_panel.groups') }
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user