mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-27 18:53:47 +02:00
508 lines
24 KiB
Vue
508 lines
24 KiB
Vue
<template>
|
|
<div class="x-container flex flex-col overflow-hidden">
|
|
<Tabs
|
|
v-model="activeSearchTab"
|
|
:unmount-on-hide="false"
|
|
aria-label="Search tabs"
|
|
class="flex flex-col min-h-0 flex-1">
|
|
<div class="mt-0 mx-0 mb-2 flex items-center gap-5">
|
|
<TabsList>
|
|
<TabsTrigger value="user">{{ t('view.search.user.header') }}</TabsTrigger>
|
|
<TabsTrigger value="world">{{ t('view.search.world.header') }}</TabsTrigger>
|
|
<TabsTrigger value="avatar">{{ t('view.search.avatar.header') }}</TabsTrigger>
|
|
<TabsTrigger value="group">{{ t('view.search.group.header') }}</TabsTrigger>
|
|
</TabsList>
|
|
<div class="flex min-w-0 flex-1 items-center">
|
|
<InputGroupField
|
|
:model-value="searchText"
|
|
:placeholder="searchPlaceholder"
|
|
style="flex: 1"
|
|
clearable
|
|
@input="updateSearchText"
|
|
@keyup.enter="search" />
|
|
<TooltipWrapper side="bottom" :content="t('view.search.clear_results_tooltip')">
|
|
<Button class="rounded-full ml-2" size="icon" variant="ghost" @click="handleClearSearch">
|
|
<Trash2 />
|
|
</Button>
|
|
</TooltipWrapper>
|
|
</div>
|
|
</div>
|
|
<TabsContent value="user" class="flex flex-col min-h-0 flex-1">
|
|
<div class="flex flex-col min-h-0" style="flex: 9">
|
|
<div class="shrink-0 mb-3 flex justify-end">
|
|
<label class="inline-flex items-center gap-2 ml-2">
|
|
<Checkbox v-model="searchUserByBio" />
|
|
<span>{{ t('view.search.user.search_by_bio') }}</span>
|
|
</label>
|
|
<label class="inline-flex items-center gap-2 ml-2">
|
|
<Checkbox v-model="searchUserSortByLastLoggedIn" />
|
|
<span>{{ t('view.search.user.sort_by_last_logged_in') }}</span>
|
|
</label>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto min-h-0">
|
|
<div v-if="isSearchUserLoading" class="flex items-center justify-center h-full">
|
|
<Spinner class="text-2xl" />
|
|
</div>
|
|
<template v-else-if="searchUserResults.length > 0">
|
|
<Item
|
|
v-for="user in searchUserResults"
|
|
:key="user.id"
|
|
class="cursor-pointer hover:bg-muted x-hover-list rounded-none"
|
|
@click="showUserDialog(user.id)">
|
|
<ItemMedia variant="image">
|
|
<Avatar>
|
|
<AvatarImage :src="userImage(user, true)" loading="lazy" />
|
|
<AvatarFallback>
|
|
<User class="size-5 text-muted-foreground" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</ItemMedia>
|
|
<ItemContent class="min-w-0">
|
|
<ItemTitle class="flex items-center gap-1.5 max-w-full">
|
|
<span class="truncate">{{ user.displayName }}</span>
|
|
<span
|
|
v-if="randomUserColours"
|
|
class="shrink-0 text-xs font-normal"
|
|
:class="user.$trustClass">
|
|
{{ user.$trustLevel }}
|
|
</span>
|
|
<span
|
|
v-else
|
|
class="shrink-0 text-xs font-normal"
|
|
:style="{ color: user.$userColour }">
|
|
{{ user.$trustLevel }}
|
|
</span>
|
|
<span
|
|
v-for="item in user.$languages"
|
|
:key="item.key"
|
|
class="flags shrink-0"
|
|
:class="languageClass(item.key)"
|
|
:title="item.value" />
|
|
</ItemTitle>
|
|
<ItemDescription v-if="user.bio" class="line-clamp-1 text-xs!">
|
|
{{ user.bio }}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
</template>
|
|
<DataTableEmpty v-else type="nodata" />
|
|
</div>
|
|
</div>
|
|
<SearchPagination
|
|
:show="paginationConfig.show"
|
|
:prev-disabled="paginationConfig.prevDisabled"
|
|
:next-disabled="paginationConfig.nextDisabled"
|
|
@prev="paginationConfig.onPrev"
|
|
@next="paginationConfig.onNext" />
|
|
</TabsContent>
|
|
<TabsContent value="world" class="flex flex-col min-h-0 flex-1">
|
|
<div class="flex flex-col min-h-0" style="flex: 9">
|
|
<div class="inline-flex justify-end mb-4 w-full shrink-0 gap-2">
|
|
<label class="inline-flex items-center gap-2">
|
|
<Checkbox v-model="searchWorldLabs" />
|
|
<span>{{ t('view.search.world.community_lab') }}</span>
|
|
</label>
|
|
<Select
|
|
:model-value="searchWorldCategoryIndex"
|
|
@update:modelValue="handleSearchWorldCategorySelect"
|
|
style="margin-bottom: 16px">
|
|
<SelectTrigger size="sm">
|
|
<SelectValue :placeholder="t('view.search.world.category')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem
|
|
v-for="row in cachedConfig.dynamicWorldRows"
|
|
:key="row.index"
|
|
:value="row.index">
|
|
{{ row.name }}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto min-h-0">
|
|
<div v-if="isSearchWorldLoading" class="flex items-center justify-center h-full">
|
|
<Spinner class="text-2xl" />
|
|
</div>
|
|
<template v-else-if="searchWorldResults.length > 0">
|
|
<ItemGroup class="grid grid-cols-5 gap-3">
|
|
<Item
|
|
v-for="world in searchWorldResults"
|
|
:key="world.id"
|
|
variant="outline"
|
|
size="sm"
|
|
class="cursor-pointer p-3"
|
|
as-child>
|
|
<div class="overflow-hidden" @click="showWorldDialog(world.id)">
|
|
<ItemHeader>
|
|
<img
|
|
:src="world.thumbnailImageUrl"
|
|
:alt="world.name"
|
|
loading="lazy"
|
|
class="aspect-[16/10] w-full rounded-lg object-cover" />
|
|
</ItemHeader>
|
|
<ItemContent class="min-w-0">
|
|
<TooltipWrapper side="top" :content="world.name">
|
|
<ItemTitle class="truncate w-auto">{{ world.name }}</ItemTitle>
|
|
</TooltipWrapper>
|
|
<ItemDescription v-if="world.occupants" class="line-clamp-1 text-xs">
|
|
{{ world.authorName }} ({{ world.occupants }})
|
|
</ItemDescription>
|
|
<ItemDescription v-else class="line-clamp-1 text-xs">
|
|
{{ world.authorName }}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</div>
|
|
</Item>
|
|
</ItemGroup>
|
|
</template>
|
|
<DataTableEmpty v-else type="nodata" />
|
|
</div>
|
|
</div>
|
|
<SearchPagination
|
|
:show="paginationConfig.show"
|
|
:prev-disabled="paginationConfig.prevDisabled"
|
|
:next-disabled="paginationConfig.nextDisabled"
|
|
@prev="paginationConfig.onPrev"
|
|
@next="paginationConfig.onNext" />
|
|
</TabsContent>
|
|
<TabsContent value="avatar" class="flex flex-col min-h-0 flex-1">
|
|
<div class="flex flex-col min-h-0" style="flex: 9">
|
|
<div class="shrink-0 mb-3 flex items-center justify-end gap-2">
|
|
<Select
|
|
v-if="avatarRemoteDatabaseProviderList.length > 0"
|
|
:model-value="avatarRemoteDatabaseProvider"
|
|
@update:modelValue="setAvatarProvider">
|
|
<SelectTrigger size="sm">
|
|
<SelectValue :placeholder="t('view.search.avatar.search_provider')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem
|
|
v-for="provider in avatarRemoteDatabaseProviderList.filter(Boolean)"
|
|
:key="provider"
|
|
:value="provider">
|
|
{{ provider }}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<span v-else class="text-sm text-muted-foreground">
|
|
{{ t('view.search.avatar.no_provider') }}
|
|
</span>
|
|
<Button size="sm" variant="outline" @click="isAvatarProviderDialogVisible = true">
|
|
<Settings class="size-4" />
|
|
</Button>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto min-h-0 mt-2">
|
|
<div v-if="isSearchAvatarLoading" class="flex items-center justify-center h-full">
|
|
<Spinner class="text-2xl" />
|
|
</div>
|
|
<template v-else-if="searchAvatarPage.length > 0">
|
|
<ItemGroup class="grid grid-cols-5 gap-3">
|
|
<Item
|
|
v-for="avatar in searchAvatarPage"
|
|
:key="avatar.id"
|
|
variant="outline"
|
|
size="sm"
|
|
class="cursor-pointer p-3"
|
|
as-child>
|
|
<div class="overflow-hidden" @click="showAvatarDialog(avatar.id)">
|
|
<ItemHeader>
|
|
<img
|
|
v-if="avatar.thumbnailImageUrl"
|
|
:src="avatar.thumbnailImageUrl"
|
|
:alt="avatar.name"
|
|
loading="lazy"
|
|
class="aspect-[16/10] w-full rounded-lg object-cover" />
|
|
<img
|
|
v-else-if="avatar.imageUrl"
|
|
:src="avatar.imageUrl"
|
|
:alt="avatar.name"
|
|
loading="lazy"
|
|
class="aspect-[16/10] w-full rounded-sm object-cover" />
|
|
</ItemHeader>
|
|
<ItemContent class="min-w-0">
|
|
<TooltipWrapper side="top" :content="avatar.name">
|
|
<ItemTitle class="truncate w-auto">{{ avatar.name }}</ItemTitle>
|
|
</TooltipWrapper>
|
|
<ItemDescription class="line-clamp-1 text-xs">
|
|
{{ avatar.authorName }}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</div>
|
|
</Item>
|
|
</ItemGroup>
|
|
</template>
|
|
<DataTableEmpty v-else type="nodata" />
|
|
</div>
|
|
</div>
|
|
<SearchPagination
|
|
:show="paginationConfig.show"
|
|
:prev-disabled="paginationConfig.prevDisabled"
|
|
:next-disabled="paginationConfig.nextDisabled"
|
|
@prev="paginationConfig.onPrev"
|
|
@next="paginationConfig.onNext" />
|
|
</TabsContent>
|
|
<TabsContent value="group" class="flex flex-col min-h-0 flex-1">
|
|
<div class="flex-1 overflow-y-auto min-h-0" style="flex: 9">
|
|
<div v-if="isSearchGroupLoading" class="flex items-center justify-center h-full">
|
|
<Spinner class="text-2xl" />
|
|
</div>
|
|
<template v-else-if="searchGroupResults.length > 0">
|
|
<Item
|
|
v-for="group in searchGroupResults"
|
|
:key="group.id"
|
|
class="cursor-pointer hover:bg-muted x-hover-list rounded-none"
|
|
@click="showGroupDialog(group.id)">
|
|
<ItemMedia variant="image">
|
|
<Avatar class="rounded-sm">
|
|
<AvatarImage :src="getSmallThumbnailUrl(group.iconUrl)" loading="lazy" />
|
|
<AvatarFallback>
|
|
<Users class="size-5 text-muted-foreground" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</ItemMedia>
|
|
<ItemContent class="min-w-0">
|
|
<ItemTitle class="truncate max-w-full">
|
|
{{ group.name }}
|
|
<span class="font-normal">({{ group.memberCount }})</span>
|
|
<span class="text-muted-foreground font-mono text-xs font-normal">
|
|
{{ group.shortCode }}.{{ group.discriminator }}
|
|
</span>
|
|
</ItemTitle>
|
|
<ItemDescription class="truncate text-xs!">
|
|
{{ group.description }}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
</template>
|
|
<DataTableEmpty v-else type="nodata" />
|
|
</div>
|
|
<SearchPagination
|
|
:show="paginationConfig.show"
|
|
:prev-disabled="paginationConfig.prevDisabled"
|
|
:next-disabled="paginationConfig.nextDisabled"
|
|
@prev="paginationConfig.onPrev"
|
|
@next="paginationConfig.onNext" />
|
|
</TabsContent>
|
|
</Tabs>
|
|
<AvatarProviderDialog v-model:isAvatarProviderDialogVisible="isAvatarProviderDialogVisible" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Settings, Trash2, User, Users } from 'lucide-vue-next';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { DataTableEmpty } from '@/components/ui/data-table';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import AvatarProviderDialog from '../Settings/dialogs/AvatarProviderDialog.vue';
|
|
import SearchPagination from './components/SearchPagination.vue';
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemGroup,
|
|
ItemHeader,
|
|
ItemMedia,
|
|
ItemTitle
|
|
} from '@/components/ui/item';
|
|
|
|
import { computed, onUnmounted, ref } from 'vue';
|
|
import { useMagicKeys, whenever } from '@vueuse/core';
|
|
import { toast } from 'vue-sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { InputGroupField } from '@/components/ui/input-group';
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
import {
|
|
useAdvancedSettingsStore,
|
|
useAppearanceSettingsStore,
|
|
useAuthStore,
|
|
useAvatarProviderStore,
|
|
useSearchStore
|
|
} from '../../stores';
|
|
import { convertFileUrlToImageUrl, languageClass, userImage } from '../../shared/utils';
|
|
import { showAvatarDialog } from '../../coordinators/avatarCoordinator';
|
|
import { showGroupDialog } from '../../coordinators/groupCoordinator';
|
|
import { showUserDialog } from '../../coordinators/userCoordinator';
|
|
import { showWorldDialog } from '../../coordinators/worldCoordinator';
|
|
import { useSearchAvatar } from './composables/useSearchAvatar';
|
|
import { useSearchWorld } from './composables/useSearchWorld';
|
|
import { useSearchUser } from './composables/useSearchUser';
|
|
import { useSearchGroup } from './composables/useSearchGroup';
|
|
|
|
const { randomUserColours } = storeToRefs(useAppearanceSettingsStore());
|
|
const { avatarRemoteDatabaseProviderList, avatarRemoteDatabaseProvider, isAvatarProviderDialogVisible } =
|
|
storeToRefs(useAvatarProviderStore());
|
|
const { setAvatarProvider } = useAvatarProviderStore();
|
|
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
|
|
|
|
const { searchText, searchUserResults } = storeToRefs(useSearchStore());
|
|
const { clearSearch } = useSearchStore();
|
|
const { cachedConfig } = storeToRefs(useAuthStore());
|
|
|
|
const { t } = useI18n();
|
|
|
|
const activeSearchTab = ref('user');
|
|
|
|
// Keyboard shortcuts: Alt+Left (prev page) / Alt+Right (next page)
|
|
const keys = useMagicKeys();
|
|
const stopPrevWatch = whenever(keys['Alt+ArrowLeft'], () => {
|
|
if (!paginationConfig.value.prevDisabled) {
|
|
paginationConfig.value.onPrev();
|
|
}
|
|
});
|
|
const stopNextWatch = whenever(keys['Alt+ArrowRight'], () => {
|
|
if (!paginationConfig.value.nextDisabled) {
|
|
paginationConfig.value.onNext();
|
|
}
|
|
});
|
|
onUnmounted(() => {
|
|
stopPrevWatch();
|
|
stopNextWatch();
|
|
});
|
|
|
|
const searchPlaceholder = computed(() => {
|
|
if (activeSearchTab.value === 'avatar') {
|
|
return t('view.search.avatar.search_placeholder_avatar');
|
|
}
|
|
return t('view.search.search_placeholder');
|
|
});
|
|
|
|
const {
|
|
searchUserParams,
|
|
searchUserByBio,
|
|
searchUserSortByLastLoggedIn,
|
|
isSearchUserLoading,
|
|
searchUser,
|
|
handleMoreSearchUser,
|
|
clearUserSearch
|
|
} = useSearchUser();
|
|
|
|
const {
|
|
searchAvatarPageNum,
|
|
searchAvatarResults,
|
|
searchAvatarPage,
|
|
isSearchAvatarLoading,
|
|
searchAvatar,
|
|
moreSearchAvatar,
|
|
clearAvatarSearch
|
|
} = useSearchAvatar();
|
|
|
|
const {
|
|
searchWorldLabs,
|
|
searchWorldParams,
|
|
searchWorldCategoryIndex,
|
|
searchWorldResults,
|
|
isSearchWorldLoading,
|
|
searchWorld,
|
|
moreSearchWorld,
|
|
handleSearchWorldCategorySelect,
|
|
clearWorldSearch
|
|
} = useSearchWorld();
|
|
|
|
const {
|
|
searchGroupParams,
|
|
searchGroupResults,
|
|
isSearchGroupLoading,
|
|
searchGroup,
|
|
moreSearchGroup,
|
|
clearGroupSearch
|
|
} = useSearchGroup();
|
|
|
|
const paginationConfig = computed(() => {
|
|
switch (activeSearchTab.value) {
|
|
case 'user':
|
|
return {
|
|
show: searchUserResults.value.length > 0 && !isSearchUserLoading.value,
|
|
prevDisabled: !searchUserParams.value.offset,
|
|
nextDisabled: searchUserResults.value.length < 10,
|
|
onPrev: () => handleMoreSearchUser(-1),
|
|
onNext: () => handleMoreSearchUser(1)
|
|
};
|
|
case 'world':
|
|
return {
|
|
show: searchWorldResults.value.length > 0 && !isSearchWorldLoading.value,
|
|
prevDisabled: !searchWorldParams.value.offset,
|
|
nextDisabled: searchWorldResults.value.length < 10,
|
|
onPrev: () => moreSearchWorld(-1),
|
|
onNext: () => moreSearchWorld(1)
|
|
};
|
|
case 'avatar':
|
|
return {
|
|
show: searchAvatarPage.value.length > 0 && !isSearchAvatarLoading.value,
|
|
prevDisabled: !searchAvatarPageNum.value,
|
|
nextDisabled:
|
|
searchAvatarResults.value.length < 10 ||
|
|
(searchAvatarPageNum.value + 1) * 10 >= searchAvatarResults.value.length,
|
|
onPrev: () => moreSearchAvatar(-1),
|
|
onNext: () => moreSearchAvatar(1)
|
|
};
|
|
case 'group':
|
|
return {
|
|
show: searchGroupResults.value.length > 0 && !isSearchGroupLoading.value,
|
|
prevDisabled: !searchGroupParams.value.offset,
|
|
nextDisabled: searchGroupResults.value.length < 10,
|
|
onPrev: () => moreSearchGroup(-1),
|
|
onNext: () => moreSearchGroup(1)
|
|
};
|
|
default:
|
|
return { show: false, prevDisabled: true, nextDisabled: true, onPrev: () => {}, onNext: () => {} };
|
|
}
|
|
});
|
|
|
|
function getSmallThumbnailUrl(url) {
|
|
return convertFileUrlToImageUrl(url);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
function handleClearSearch() {
|
|
clearUserSearch();
|
|
clearWorldSearch();
|
|
clearAvatarSearch();
|
|
clearGroupSearch();
|
|
clearSearch();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param text
|
|
*/
|
|
function updateSearchText(text) {
|
|
searchText.value = text;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
function search() {
|
|
if (activeSearchTab.value === 'avatar' && (!searchText.value || searchText.value.length < 3)) {
|
|
toast.warning(t('view.search.avatar.min_chars_warning'));
|
|
return;
|
|
}
|
|
switch (activeSearchTab.value) {
|
|
case 'user':
|
|
searchUser();
|
|
break;
|
|
case 'world':
|
|
searchWorld({});
|
|
break;
|
|
case 'avatar':
|
|
searchAvatar();
|
|
break;
|
|
case 'group':
|
|
searchGroup();
|
|
break;
|
|
}
|
|
}
|
|
</script>
|