Files
VRCX/src/views/Search/Search.vue
2026-01-11 06:05:08 +13:00

668 lines
28 KiB
Vue

<template>
<div class="x-container">
<div style="margin: 0 0 10px; display: flex; align-items: center">
<el-input
:model-value="searchText"
:placeholder="t('view.search.search_placeholder')"
style="flex: 1"
@input="updateSearchText"
@keyup.enter="search"></el-input>
<TooltipWrapper side="bottom" :content="t('view.search.clear_results_tooltip')">
<el-button
type="default"
:icon="Delete"
circle
style="flex: none; margin-left: 10px"
@click="handleClearSearch"></el-button>
</TooltipWrapper>
</div>
<el-tabs ref="searchTabRef" style="margin-top: 15px" @tab-click="searchText = ''">
<el-tab-pane v-loading="isSearchUserLoading" :label="t('view.search.user.header')" style="min-height: 60px">
<el-checkbox v-model="searchUserByBio" style="margin-left: 10px">{{
t('view.search.user.search_by_bio')
}}</el-checkbox>
<el-checkbox v-model="searchUserSortByLastLoggedIn" style="margin-left: 10px">{{
t('view.search.user.sort_by_last_logged_in')
}}</el-checkbox>
<div class="x-friend-list" style="min-height: 500px">
<div
v-for="user in searchUserResults"
:key="user.id"
class="x-friend-item"
@click="showUserDialog(user.id)">
<div class="avatar">
<img :src="userImage(user, true)" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="user.displayName"></span>
<span
v-if="randomUserColours"
class="extra"
:class="user.$trustClass"
v-text="user.$trustLevel"></span>
<span
v-else
class="extra"
:style="{ color: user.$userColour }"
v-text="user.$trustLevel"></span>
</div>
</div>
</div>
<el-button-group v-if="searchUserResults.length" style="margin-top: 15px">
<el-button
:disabled="!searchUserParams.offset"
:icon="Back"
size="small"
@click="handleMoreSearchUser(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchUserResults.length < 10"
:icon="Right"
size="small"
@click="handleMoreSearchUser(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
<el-tab-pane
v-loading="isSearchWorldLoading"
:label="t('view.search.world.header')"
style="min-height: 60px">
<el-dropdown
size="small"
trigger="click"
style="margin-bottom: 15px"
@command="(row) => searchWorld(row)">
<el-button size="small"
>{{ t('view.search.world.category') }} <el-icon class="el-icon--right"><ArrowDown /></el-icon
></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="row in cachedConfig.dynamicWorldRows"
:key="row.index"
:command="row"
v-text="row.name"></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-checkbox v-model="searchWorldLabs" style="margin-left: 10px">{{
t('view.search.world.community_lab')
}}</el-checkbox>
<div class="x-friend-list" style="min-height: 500px">
<div
v-for="world in searchWorldResults"
:key="world.id"
class="x-friend-item"
@click="showWorldDialog(world.id)">
<div class="avatar">
<img :src="world.thumbnailImageUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="world.name"></span>
<span v-if="world.occupants" class="extra"
>{{ world.authorName }} ({{ world.occupants }})</span
>
<span v-else class="extra" v-text="world.authorName"></span>
</div>
</div>
</div>
<el-button-group v-if="searchWorldResults.length" style="margin-top: 15px">
<el-button
:disabled="!searchWorldParams.offset"
:icon="Back"
size="small"
@click="moreSearchWorld(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchWorldResults.length < 10"
:icon="Right"
size="small"
@click="moreSearchWorld(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
<el-tab-pane
v-loading="isSearchAvatarLoading"
:label="t('view.search.avatar.header')"
style="min-height: 60px">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="display: flex; align-items: center">
<el-dropdown
v-if="avatarRemoteDatabaseProviderList.length > 1"
trigger="click"
size="small"
style="margin-right: 5px"
@click.stop>
<el-button size="small"
>{{ t('view.search.avatar.search_provider') }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon
></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="provider in avatarRemoteDatabaseProviderList"
:key="provider"
@click="setAvatarProvider(provider)">
<el-icon v-if="provider === avatarRemoteDatabaseProvider" class="el-icon--left"
><Check
/></el-icon>
{{ provider }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<TooltipWrapper side="bottom" :content="t('view.search.avatar.refresh_tooltip')">
<el-button
type="default"
:loading="userDialog.isAvatarsLoading"
size="small"
:icon="Refresh"
circle
@click="refreshUserDialogAvatars"></el-button>
</TooltipWrapper>
<span style="font-size: 14px; margin-left: 5px; margin-right: 5px">{{
t('view.search.avatar.result_count', {
count: searchAvatarResults.length
})
}}</span>
</div>
<div style="display: flex; align-items: center">
<el-radio-group
v-model="searchAvatarFilter"
size="small"
style="margin: 5px; display: block"
@change="searchAvatar">
<el-radio value="all">{{ t('view.search.avatar.all') }}</el-radio>
<el-radio value="public">{{ t('view.search.avatar.public') }}</el-radio>
<el-radio value="private">{{ t('view.search.avatar.private') }}</el-radio>
</el-radio-group>
<el-divider direction="vertical"></el-divider>
<el-radio-group
v-model="searchAvatarFilterRemote"
size="small"
style="margin: 5px; display: block"
@change="searchAvatar">
<el-radio value="all">{{ t('view.search.avatar.all') }}</el-radio>
<el-radio value="local">{{ t('view.search.avatar.local') }}</el-radio>
<el-radio value="remote" :disabled="!avatarRemoteDatabase">{{
t('view.search.avatar.remote')
}}</el-radio>
</el-radio-group>
</div>
</div>
<div style="display: flex; justify-content: end">
<el-radio-group
v-model="searchAvatarSort"
:disabled="searchAvatarFilterRemote !== 'local'"
size="small"
style="margin: 5px; display: block"
@change="searchAvatar">
<el-radio value="name">{{ t('view.search.avatar.sort_name') }}</el-radio>
<el-radio value="update">{{ t('view.search.avatar.sort_update') }}</el-radio>
<el-radio value="created">{{ t('view.search.avatar.sort_created') }}</el-radio>
</el-radio-group>
</div>
<div class="x-friend-list" style="margin-top: 20px; min-height: 500px">
<div
v-for="avatar in searchAvatarPage"
:key="avatar.id"
class="x-friend-item"
@click="showAvatarDialog(avatar.id)">
<div class="avatar">
<img v-if="avatar.thumbnailImageUrl" :src="avatar.thumbnailImageUrl" loading="lazy" />
<img v-else-if="avatar.imageUrl" :src="avatar.imageUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="avatar.name"></span>
<span
v-if="avatar.releaseStatus === 'public'"
class="extra"
style="color: var(--el-color-success)"
v-text="avatar.releaseStatus"></span>
<span
v-else-if="avatar.releaseStatus === 'private'"
class="extra"
style="color: var(--el-color-danger)"
v-text="avatar.releaseStatus"></span>
<span v-else class="extra" v-text="avatar.releaseStatus"></span>
<span class="extra" v-text="avatar.authorName"></span>
</div>
</div>
</div>
<el-button-group v-if="searchAvatarPage.length" style="margin-top: 15px">
<el-button
:disabled="!searchAvatarPageNum"
:icon="Back"
size="small"
@click="moreSearchAvatar(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="
searchAvatarResults.length < 10 ||
(searchAvatarPageNum + 1) * 10 >= searchAvatarResults.length
"
:icon="Right"
size="small"
@click="moreSearchAvatar(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
<el-tab-pane
v-loading="isSearchGroupLoading"
:label="t('view.search.group.header')"
style="min-height: 60px">
<div class="x-friend-list" style="min-height: 500px">
<div
v-for="group in searchGroupResults"
:key="group.id"
class="x-friend-item"
@click="showGroupDialog(group.id)">
<div class="avatar">
<img :src="getSmallThumbnailUrl(group.iconUrl)" loading="lazy" />
</div>
<div class="detail">
<span class="name">
<span v-text="group.name"></span>
<span style="margin-left: 5px; font-weight: normal">({{ group.memberCount }})</span>
<span
style="
margin-left: 5px;
color: #909399;
font-weight: normal;
font-family: monospace;
font-size: 12px;
"
>{{ group.shortCode }}.{{ group.discriminator }}</span
>
</span>
<span class="extra" v-text="group.description"></span>
</div>
</div>
</div>
<el-button-group v-if="searchGroupResults.length" style="margin-top: 15px">
<el-button
:disabled="!searchGroupParams.offset"
:icon="Back"
size="small"
@click="moreSearchGroup(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchGroupResults.length < 10"
:icon="Right"
size="small"
@click="moreSearchGroup(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ArrowDown, Back, Check, Delete, Refresh, Right } from '@element-plus/icons-vue';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useAuthStore,
useAvatarProviderStore,
useAvatarStore,
useGroupStore,
useSearchStore,
useUserStore,
useWorldStore
} from '../../stores';
import {
compareByCreatedAt,
compareByName,
compareByUpdatedAt,
convertFileUrlToImageUrl,
replaceBioSymbols,
userImage
} from '../../shared/utils';
import { groupRequest, worldRequest } from '../../api';
const { randomUserColours } = storeToRefs(useAppearanceSettingsStore());
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
const { avatarRemoteDatabaseProviderList, avatarRemoteDatabaseProvider } = storeToRefs(useAvatarProviderStore());
const { setAvatarProvider } = useAvatarProviderStore();
const { userDialog } = storeToRefs(useUserStore());
const { showUserDialog, refreshUserDialogAvatars } = useUserStore();
const { showAvatarDialog, lookupAvatars, cachedAvatars } = useAvatarStore();
const { cachedWorlds, showWorldDialog } = useWorldStore();
const { showGroupDialog, applyGroup } = useGroupStore();
const { searchText, searchUserResults } = storeToRefs(useSearchStore());
const { clearSearch, moreSearchUser } = useSearchStore();
const { cachedConfig } = storeToRefs(useAuthStore());
const { t } = useI18n();
const searchTabRef = ref(null);
const searchUserParams = ref({});
const searchUserByBio = ref(false);
const searchUserSortByLastLoggedIn = ref(false);
const isSearchUserLoading = ref(false);
const isSearchWorldLoading = ref(false);
const isSearchAvatarLoading = ref(false);
const isSearchGroupLoading = ref(false);
const searchWorldOption = ref('');
const searchWorldLabs = ref(false);
const searchWorldParams = ref({});
const searchWorldResults = ref([]);
const searchAvatarFilter = ref('');
const searchAvatarSort = ref('');
const searchAvatarFilterRemote = ref('');
const searchAvatarPageNum = ref(0);
const searchAvatarResults = ref([]);
const searchAvatarPage = ref([]);
const searchGroupParams = ref({});
const searchGroupResults = ref([]);
function getSmallThumbnailUrl(url) {
return convertFileUrlToImageUrl(url);
}
function handleClearSearch() {
searchUserParams.value = {};
searchWorldParams.value = {};
searchWorldResults.value = [];
searchAvatarResults.value = [];
searchAvatarPage.value = [];
searchAvatarPageNum.value = 0;
searchGroupParams.value = {};
searchGroupResults.value = [];
clearSearch();
}
function updateSearchText(text) {
searchText.value = text;
}
function search() {
switch (searchTabRef.value.currentName) {
case '0':
searchUser();
break;
case '1':
searchWorld({});
break;
case '2':
searchAvatar();
break;
case '3':
searchGroup();
break;
}
}
async function searchUser() {
searchUserParams.value = {
n: 10,
offset: 0,
search: searchText.value,
customFields: searchUserByBio.value ? 'bio' : 'displayName',
sort: searchUserSortByLastLoggedIn.value ? 'last_login' : 'relevance'
};
await handleMoreSearchUser();
}
async function handleMoreSearchUser(go = null) {
isSearchUserLoading.value = true;
await moreSearchUser(go, searchUserParams.value);
isSearchUserLoading.value = false;
}
function searchWorld(ref) {
searchWorldOption.value = '';
const params = {
n: 10,
offset: 0
};
switch (ref.sortHeading) {
case 'featured':
params.sort = 'order';
params.featured = 'true';
break;
case 'trending':
params.sort = 'popularity';
params.featured = 'false';
break;
case 'updated':
params.sort = 'updated';
break;
case 'created':
params.sort = 'created';
break;
case 'publication':
params.sort = 'publicationDate';
break;
case 'shuffle':
params.sort = 'shuffle';
break;
case 'active':
searchWorldOption.value = 'active';
break;
case 'recent':
searchWorldOption.value = 'recent';
break;
case 'favorite':
searchWorldOption.value = 'favorites';
break;
case 'labs':
params.sort = 'labsPublicationDate';
break;
case 'heat':
params.sort = 'heat';
params.featured = 'false';
break;
default:
params.sort = 'relevance';
params.search = replaceBioSymbols(searchText.value);
break;
}
params.order = ref.sortOrder || 'descending';
if (ref.sortOwnership === 'mine') {
params.user = 'me';
params.releaseStatus = 'all';
}
if (ref.tag) {
params.tag = ref.tag;
}
if (!searchWorldLabs.value) {
if (params.tag) {
params.tag += ',system_approved';
} else {
params.tag = 'system_approved';
}
}
// TODO: option.platform
searchWorldParams.value = params;
moreSearchWorld();
}
function moreSearchWorld(go) {
const params = searchWorldParams.value;
if (go) {
params.offset += params.n * go;
if (params.offset < 0) {
params.offset = 0;
}
}
isSearchWorldLoading.value = true;
worldRequest
.getWorlds(params, searchWorldOption.value)
.finally(() => {
isSearchWorldLoading.value = false;
})
.then((args) => {
const map = new Map();
for (const json of args.json) {
const ref = cachedWorlds.get(json.id);
if (typeof ref !== 'undefined') {
map.set(ref.id, ref);
}
}
searchWorldResults.value = Array.from(map.values());
return args;
});
}
async function searchAvatar() {
let ref;
isSearchAvatarLoading.value = true;
if (!searchAvatarFilter.value) {
searchAvatarFilter.value = 'all';
}
if (!searchAvatarSort.value) {
searchAvatarSort.value = 'name';
}
if (!searchAvatarFilterRemote.value) {
searchAvatarFilterRemote.value = 'all';
}
if (searchAvatarFilterRemote.value !== 'local') {
searchAvatarSort.value = 'name';
}
const avatars = new Map();
const query = searchText.value;
const queryUpper = query.toUpperCase();
if (!query) {
for (ref of cachedAvatars.values()) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
break;
case 'public':
if (ref.releaseStatus === 'public') {
avatars.set(ref.id, ref);
}
break;
case 'private':
if (ref.releaseStatus === 'private') {
avatars.set(ref.id, ref);
}
break;
}
}
isSearchAvatarLoading.value = false;
} else {
if (searchAvatarFilterRemote.value === 'all' || searchAvatarFilterRemote.value === 'local') {
for (ref of cachedAvatars.values()) {
let match = ref.name.toUpperCase().includes(queryUpper);
if (!match && ref.description) {
match = ref.description.toUpperCase().includes(queryUpper);
}
if (!match && ref.authorName) {
match = ref.authorName.toUpperCase().includes(queryUpper);
}
if (match) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
break;
case 'public':
if (ref.releaseStatus === 'public') {
avatars.set(ref.id, ref);
}
break;
case 'private':
if (ref.releaseStatus === 'private') {
avatars.set(ref.id, ref);
}
break;
}
}
}
}
if (
(searchAvatarFilterRemote.value === 'all' || searchAvatarFilterRemote.value === 'remote') &&
avatarRemoteDatabase.value &&
query.length >= 3
) {
const data = await lookupAvatars('search', query);
if (data && typeof data === 'object') {
data.forEach((avatar) => {
avatars.set(avatar.id, avatar);
});
}
}
isSearchAvatarLoading.value = false;
}
const avatarsArray = Array.from(avatars.values());
if (searchAvatarFilterRemote.value === 'local') {
switch (searchAvatarSort.value) {
case 'updated':
avatarsArray.sort(compareByUpdatedAt);
break;
case 'created':
avatarsArray.sort(compareByCreatedAt);
break;
case 'name':
avatarsArray.sort(compareByName);
break;
}
}
searchAvatarPageNum.value = 0;
searchAvatarResults.value = avatarsArray;
searchAvatarPage.value = avatarsArray.slice(0, 10);
}
function moreSearchAvatar(n) {
let offset;
if (n === -1) {
searchAvatarPageNum.value--;
offset = searchAvatarPageNum.value * 10;
}
if (n === 1) {
searchAvatarPageNum.value++;
offset = searchAvatarPageNum.value * 10;
}
searchAvatarPage.value = searchAvatarResults.value.slice(offset, offset + 10);
}
async function searchGroup() {
searchGroupParams.value = {
n: 10,
offset: 0,
query: replaceBioSymbols(searchText.value)
};
await moreSearchGroup();
}
async function moreSearchGroup(go) {
const params = searchGroupParams.value;
if (go) {
params.offset += params.n * go;
if (params.offset < 0) {
params.offset = 0;
}
}
isSearchGroupLoading.value = true;
await groupRequest
.groupSearch(params)
.finally(() => {
isSearchGroupLoading.value = false;
})
.then((args) => {
const map = new Map();
for (const json of args.json) {
map.set(json.id, json);
}
searchGroupResults.value = Array.from(map.values());
return args;
});
}
</script>