feat: add quick search

This commit is contained in:
pa
2026-03-05 22:20:07 +09:00
parent b570de6d4a
commit fb6358b3be
13 changed files with 1411 additions and 106 deletions

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Kbd } from './Kbd.vue';
export { default as KbdGroup } from './KbdGroup.vue';

View File

@@ -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:",

View 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([]);
});
});

View 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;
}

View File

@@ -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
View 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
};
});

View File

@@ -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
};

View File

@@ -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
};
});

View File

@@ -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>