mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 14:56:06 +02:00
improve search tab
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
'data-[state=active]:bg-background data-[state=active]:border dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -221,6 +221,9 @@
|
|||||||
"avatar": {
|
"avatar": {
|
||||||
"header": "Avatar",
|
"header": "Avatar",
|
||||||
"search_provider": "Search Provider",
|
"search_provider": "Search Provider",
|
||||||
|
"no_provider": "No avatar search provider configured",
|
||||||
|
"search_placeholder_avatar": "Type at least 3 characters",
|
||||||
|
"min_chars_warning": "Please enter at least 3 characters to search",
|
||||||
"refresh_tooltip": "Refresh own avatars",
|
"refresh_tooltip": "Refresh own avatars",
|
||||||
"result_count": "Results {count}",
|
"result_count": "Results {count}",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|||||||
@@ -111,12 +111,8 @@ export const useAvatarProviderStore = defineStore('AvatarProvider', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAvatarProviderList() {
|
async function saveAvatarProviderList() {
|
||||||
const length = avatarRemoteDatabaseProviderList.value.length;
|
avatarRemoteDatabaseProviderList.value =
|
||||||
for (let i = 0; i < length; ++i) {
|
avatarRemoteDatabaseProviderList.value.filter(Boolean);
|
||||||
if (!avatarRemoteDatabaseProviderList.value[i]) {
|
|
||||||
avatarRemoteDatabaseProviderList.value.splice(i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await configRepository.setString(
|
await configRepository.setString(
|
||||||
'VRCX_avatarRemoteDatabaseProviderList',
|
'VRCX_avatarRemoteDatabaseProviderList',
|
||||||
JSON.stringify(avatarRemoteDatabaseProviderList.value)
|
JSON.stringify(avatarRemoteDatabaseProviderList.value)
|
||||||
|
|||||||
+327
-402
@@ -1,27 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="x-container">
|
<div class="x-container flex flex-col overflow-hidden">
|
||||||
<div class="mt-0 mx-0 mb-2" style="display: flex; align-items: center">
|
<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
|
<InputGroupField
|
||||||
:model-value="searchText"
|
:model-value="searchText"
|
||||||
:placeholder="t('view.search.search_placeholder')"
|
:placeholder="searchPlaceholder"
|
||||||
style="flex: 1"
|
style="flex: 1"
|
||||||
clearable
|
clearable
|
||||||
@input="updateSearchText"
|
@input="updateSearchText"
|
||||||
@keyup.enter="search" />
|
@keyup.enter="search" />
|
||||||
<TooltipWrapper side="bottom" :content="t('view.search.clear_results_tooltip')">
|
<TooltipWrapper side="bottom" :content="t('view.search.clear_results_tooltip')">
|
||||||
<Button class="rounded-full ml-2" size="icon" variant="ghost" @click="handleClearSearch"
|
<Button class="rounded-full ml-2" size="icon" variant="ghost" @click="handleClearSearch">
|
||||||
><Trash2
|
<Trash2 />
|
||||||
/></Button>
|
</Button>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
</div>
|
</div>
|
||||||
<TabsUnderline
|
</div>
|
||||||
class="mt-4"
|
<TabsContent value="user" class="flex flex-col min-h-0 flex-1">
|
||||||
v-model="activeSearchTab"
|
<div class="flex flex-col min-h-0" style="flex: 9">
|
||||||
:items="searchTabs"
|
<div class="shrink-0 mb-3 flex justify-end">
|
||||||
aria-label="Search tabs"
|
|
||||||
:unmount-on-hide="false">
|
|
||||||
<template #user>
|
|
||||||
<div style="min-height: 60px">
|
|
||||||
<label class="inline-flex items-center gap-2 ml-2">
|
<label class="inline-flex items-center gap-2 ml-2">
|
||||||
<Checkbox v-model="searchUserByBio" />
|
<Checkbox v-model="searchUserByBio" />
|
||||||
<span>{{ t('view.search.user.search_by_bio') }}</span>
|
<span>{{ t('view.search.user.search_by_bio') }}</span>
|
||||||
@@ -30,58 +38,70 @@
|
|||||||
<Checkbox v-model="searchUserSortByLastLoggedIn" />
|
<Checkbox v-model="searchUserSortByLastLoggedIn" />
|
||||||
<span>{{ t('view.search.user.sort_by_last_logged_in') }}</span>
|
<span>{{ t('view.search.user.sort_by_last_logged_in') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div style="min-height: 500px">
|
</div>
|
||||||
<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"
|
v-for="user in searchUserResults"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer hover:bg-muted/50 hover:rounded-lg"
|
class="cursor-pointer hover:bg-muted x-hover-list rounded-none"
|
||||||
@click="showUserDialog(user.id)">
|
@click="showUserDialog(user.id)">
|
||||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
<ItemMedia variant="image">
|
||||||
<img
|
<Avatar>
|
||||||
class="size-full rounded-full object-cover"
|
<AvatarImage :src="userImage(user, true)" loading="lazy" />
|
||||||
:src="userImage(user, true)"
|
<AvatarFallback>
|
||||||
loading="lazy" />
|
<User class="size-5 text-muted-foreground" />
|
||||||
</div>
|
</AvatarFallback>
|
||||||
<div class="flex-1 overflow-hidden">
|
</Avatar>
|
||||||
<span
|
</ItemMedia>
|
||||||
class="block truncate font-medium leading-[18px]"
|
<ItemContent class="min-w-0">
|
||||||
v-text="user.displayName"></span>
|
<ItemTitle class="flex items-center gap-1.5 max-w-full">
|
||||||
|
<span class="truncate">{{ user.displayName }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="randomUserColours"
|
v-if="randomUserColours"
|
||||||
class="block truncate text-xs"
|
class="shrink-0 text-xs font-normal"
|
||||||
:class="user.$trustClass"
|
:class="user.$trustClass">
|
||||||
v-text="user.$trustLevel"></span>
|
{{ user.$trustLevel }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="block truncate text-xs"
|
class="shrink-0 text-xs font-normal"
|
||||||
:style="{ color: user.$userColour }"
|
:style="{ color: user.$userColour }">
|
||||||
v-text="user.$trustLevel"></span>
|
{{ user.$trustLevel }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span
|
||||||
</div>
|
v-for="item in user.$languages"
|
||||||
<ButtonGroup class="mt-4" v-if="searchUserResults.length">
|
:key="item.key"
|
||||||
<Button
|
class="flags shrink-0"
|
||||||
variant="outline"
|
:class="languageClass(item.key)"
|
||||||
size="sm"
|
:title="item.value" />
|
||||||
:disabled="!searchUserParams.offset"
|
</ItemTitle>
|
||||||
@click="handleMoreSearchUser(-1)">
|
<ItemDescription v-if="user.bio" class="line-clamp-1 text-xs!">
|
||||||
<ArrowLeft />
|
{{ user.bio }}
|
||||||
{{ t('view.search.prev_page') }}
|
</ItemDescription>
|
||||||
</Button>
|
</ItemContent>
|
||||||
<Button
|
</Item>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="searchUserResults.length < 10"
|
|
||||||
@click="handleMoreSearchUser(1)">
|
|
||||||
<ArrowRight />
|
|
||||||
{{ t('view.search.next_page') }}
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #world>
|
<DataTableEmpty v-else type="nodata" />
|
||||||
<div style="min-height: 60px">
|
</div>
|
||||||
<div class="inline-flex justify-between mb-4 w-full">
|
</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
|
<Select
|
||||||
:model-value="searchWorldCategoryIndex"
|
:model-value="searchWorldCategoryIndex"
|
||||||
@update:modelValue="handleSearchWorldCategorySelect"
|
@update:modelValue="handleSearchWorldCategorySelect"
|
||||||
@@ -100,68 +120,67 @@
|
|||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<label class="inline-flex items-center gap-2" style="margin-left: 8px">
|
|
||||||
<Checkbox v-model="searchWorldLabs" />
|
|
||||||
<span>{{ t('view.search.world.community_lab') }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="min-height: 500px">
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
<div
|
<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"
|
v-for="world in searchWorldResults"
|
||||||
:key="world.id"
|
:key="world.id"
|
||||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer hover:bg-muted/50 hover:rounded-lg"
|
variant="outline"
|
||||||
@click="showWorldDialog(world.id)">
|
size="sm"
|
||||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
class="cursor-pointer p-3"
|
||||||
|
as-child>
|
||||||
|
<div class="overflow-hidden" @click="showWorldDialog(world.id)">
|
||||||
|
<ItemHeader>
|
||||||
<img
|
<img
|
||||||
class="size-full rounded-full object-cover"
|
|
||||||
:src="world.thumbnailImageUrl"
|
:src="world.thumbnailImageUrl"
|
||||||
loading="lazy" />
|
:alt="world.name"
|
||||||
</div>
|
loading="lazy"
|
||||||
<div class="flex-1 overflow-hidden">
|
class="aspect-[16/10] w-full rounded-lg object-cover" />
|
||||||
<span class="block truncate font-medium leading-[18px]" v-text="world.name"></span>
|
</ItemHeader>
|
||||||
<span v-if="world.occupants" class="block truncate text-xs"
|
<ItemContent class="min-w-0">
|
||||||
>{{ world.authorName }} ({{ world.occupants }})</span
|
<TooltipWrapper side="top" :content="world.name">
|
||||||
>
|
<ItemTitle class="truncate w-auto">{{ world.name }}</ItemTitle>
|
||||||
<span v-else class="block truncate text-xs" v-text="world.authorName"></span>
|
</TooltipWrapper>
|
||||||
</div>
|
<ItemDescription v-if="world.occupants" class="line-clamp-1 text-xs">
|
||||||
</div>
|
{{ world.authorName }} ({{ world.occupants }})
|
||||||
</div>
|
</ItemDescription>
|
||||||
<ButtonGroup v-if="searchWorldResults.length" style="margin-top: 16px">
|
<ItemDescription v-else class="line-clamp-1 text-xs">
|
||||||
<Button
|
{{ world.authorName }}
|
||||||
variant="outline"
|
</ItemDescription>
|
||||||
size="sm"
|
</ItemContent>
|
||||||
:disabled="!searchWorldParams.offset"
|
|
||||||
@click="moreSearchWorld(-1)">
|
|
||||||
<ArrowLeft />
|
|
||||||
{{ t('view.search.prev_page') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="searchWorldResults.length < 10"
|
|
||||||
@click="moreSearchWorld(1)">
|
|
||||||
<ArrowRight />
|
|
||||||
{{ t('view.search.next_page') }}
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
</template>
|
</template>
|
||||||
<template #avatar>
|
<DataTableEmpty v-else type="nodata" />
|
||||||
<div style="min-height: 60px">
|
</div>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
</div>
|
||||||
<div style="display: flex; align-items: center">
|
<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
|
<Select
|
||||||
v-if="avatarRemoteDatabaseProviderList.length > 1"
|
v-if="avatarRemoteDatabaseProviderList.length > 0"
|
||||||
:model-value="avatarRemoteDatabaseProvider"
|
:model-value="avatarRemoteDatabaseProvider"
|
||||||
@update:modelValue="setAvatarProvider"
|
@update:modelValue="setAvatarProvider">
|
||||||
style="margin-right: 6px">
|
|
||||||
<SelectTrigger size="sm">
|
<SelectTrigger size="sm">
|
||||||
<SelectValue :placeholder="t('view.search.avatar.search_provider')" />
|
<SelectValue :placeholder="t('view.search.avatar.search_provider')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="provider in avatarRemoteDatabaseProviderList"
|
v-for="provider in avatarRemoteDatabaseProviderList.filter(Boolean)"
|
||||||
:key="provider"
|
:key="provider"
|
||||||
:value="provider">
|
:value="provider">
|
||||||
{{ provider }}
|
{{ provider }}
|
||||||
@@ -169,216 +188,136 @@
|
|||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<TooltipWrapper side="bottom" :content="t('view.search.avatar.refresh_tooltip')">
|
<span v-else class="text-sm text-muted-foreground">
|
||||||
<Button
|
{{ t('view.search.avatar.no_provider') }}
|
||||||
class="rounded-full ml-1"
|
</span>
|
||||||
variant="ghost"
|
<Button size="sm" variant="outline" @click="isAvatarProviderDialogVisible = true">
|
||||||
size="icon-sm"
|
<Settings class="size-4" />
|
||||||
:disabled="userDialog.isAvatarsLoading"
|
|
||||||
@click="refreshUserDialogAvatars">
|
|
||||||
<Spinner v-if="userDialog.isAvatarsLoading" />
|
|
||||||
<RefreshCw v-else />
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipWrapper>
|
|
||||||
<span class="text-sm mx-1.5">{{
|
|
||||||
t('view.search.avatar.result_count', {
|
|
||||||
count: searchAvatarResults.length
|
|
||||||
})
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center">
|
<div class="flex-1 overflow-y-auto min-h-0 mt-2">
|
||||||
<RadioGroup
|
<div v-if="isSearchAvatarLoading" class="flex items-center justify-center h-full">
|
||||||
:model-value="searchAvatarFilter"
|
<Spinner class="text-2xl" />
|
||||||
class="flex items-center gap-4"
|
|
||||||
style="margin: 6px"
|
|
||||||
@update:modelValue="handleSearchAvatarFilterChange">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem id="searchAvatarFilter-all" value="all" />
|
|
||||||
<label for="searchAvatarFilter-all">{{ t('view.search.avatar.all') }}</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<template v-else-if="searchAvatarPage.length > 0">
|
||||||
<RadioGroupItem id="searchAvatarFilter-public" value="public" />
|
<ItemGroup class="grid grid-cols-5 gap-3">
|
||||||
<label for="searchAvatarFilter-public">{{ t('view.search.avatar.public') }}</label>
|
<Item
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem id="searchAvatarFilter-private" value="private" />
|
|
||||||
<label for="searchAvatarFilter-private">{{
|
|
||||||
t('view.search.avatar.private')
|
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
<Separator orientation="vertical" class="mx-2 h-5" />
|
|
||||||
<RadioGroup
|
|
||||||
:model-value="searchAvatarFilterRemote"
|
|
||||||
class="flex items-center gap-4"
|
|
||||||
style="margin: 6px"
|
|
||||||
@update:modelValue="handleSearchAvatarFilterRemoteChange">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem id="searchAvatarFilterRemote-all" value="all" />
|
|
||||||
<label for="searchAvatarFilterRemote-all">{{ t('view.search.avatar.all') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem id="searchAvatarFilterRemote-local" value="local" />
|
|
||||||
<label for="searchAvatarFilterRemote-local">{{
|
|
||||||
t('view.search.avatar.local')
|
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem
|
|
||||||
id="searchAvatarFilterRemote-remote"
|
|
||||||
value="remote"
|
|
||||||
:disabled="!avatarRemoteDatabase" />
|
|
||||||
<label for="searchAvatarFilterRemote-remote">{{
|
|
||||||
t('view.search.avatar.remote')
|
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: end" class="mt-2">
|
|
||||||
<Select
|
|
||||||
:model-value="searchAvatarSort"
|
|
||||||
:disabled="searchAvatarFilterRemote !== 'local'"
|
|
||||||
style="margin: 6px"
|
|
||||||
@update:modelValue="handleSearchAvatarSortChange">
|
|
||||||
<SelectTrigger size="sm">
|
|
||||||
<SelectValue :placeholder="t('view.search.avatar.sort_name')" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="name">
|
|
||||||
{{ t('view.search.avatar.sort_name') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="update">
|
|
||||||
{{ t('view.search.avatar.sort_update') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="created">
|
|
||||||
{{ t('view.search.avatar.sort_created') }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 20px; min-height: 500px">
|
|
||||||
<div
|
|
||||||
v-for="avatar in searchAvatarPage"
|
v-for="avatar in searchAvatarPage"
|
||||||
:key="avatar.id"
|
:key="avatar.id"
|
||||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer hover:bg-muted/50 hover:rounded-lg"
|
variant="outline"
|
||||||
@click="showAvatarDialog(avatar.id)">
|
size="sm"
|
||||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
class="cursor-pointer p-3"
|
||||||
|
as-child>
|
||||||
|
<div class="overflow-hidden" @click="showAvatarDialog(avatar.id)">
|
||||||
|
<ItemHeader>
|
||||||
<img
|
<img
|
||||||
v-if="avatar.thumbnailImageUrl"
|
v-if="avatar.thumbnailImageUrl"
|
||||||
class="size-full rounded-full object-cover"
|
|
||||||
:src="avatar.thumbnailImageUrl"
|
:src="avatar.thumbnailImageUrl"
|
||||||
loading="lazy" />
|
:alt="avatar.name"
|
||||||
|
loading="lazy"
|
||||||
|
class="aspect-[16/10] w-full rounded-lg object-cover" />
|
||||||
<img
|
<img
|
||||||
v-else-if="avatar.imageUrl"
|
v-else-if="avatar.imageUrl"
|
||||||
class="size-full rounded-full object-cover"
|
|
||||||
:src="avatar.imageUrl"
|
:src="avatar.imageUrl"
|
||||||
loading="lazy" />
|
:alt="avatar.name"
|
||||||
</div>
|
loading="lazy"
|
||||||
<div class="flex-1 overflow-hidden">
|
class="aspect-[16/10] w-full rounded-sm object-cover" />
|
||||||
<span class="block truncate font-medium leading-[18px]" v-text="avatar.name"></span>
|
</ItemHeader>
|
||||||
<span
|
<ItemContent class="min-w-0">
|
||||||
v-if="avatar.releaseStatus === 'public'"
|
<TooltipWrapper side="top" :content="avatar.name">
|
||||||
class="block truncate text-xs"
|
<ItemTitle class="truncate w-auto">{{ avatar.name }}</ItemTitle>
|
||||||
v-text="avatar.releaseStatus"></span>
|
</TooltipWrapper>
|
||||||
<span
|
<ItemDescription class="line-clamp-1 text-xs">
|
||||||
v-else-if="avatar.releaseStatus === 'private'"
|
{{ avatar.authorName }}
|
||||||
class="block truncate text-xs"
|
</ItemDescription>
|
||||||
v-text="avatar.releaseStatus"></span>
|
</ItemContent>
|
||||||
<span v-else class="block truncate text-xs" v-text="avatar.releaseStatus"></span>
|
|
||||||
<span class="block truncate text-xs" v-text="avatar.authorName"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ButtonGroup v-if="searchAvatarPage.length" style="margin-top: 16px">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="!searchAvatarPageNum"
|
|
||||||
@click="moreSearchAvatar(-1)">
|
|
||||||
<ArrowLeft />
|
|
||||||
{{ t('view.search.prev_page') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="
|
|
||||||
searchAvatarResults.length < 10 ||
|
|
||||||
(searchAvatarPageNum + 1) * 10 >= searchAvatarResults.length
|
|
||||||
"
|
|
||||||
@click="moreSearchAvatar(1)">
|
|
||||||
<ArrowRight />
|
|
||||||
{{ t('view.search.next_page') }}
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
</template>
|
</template>
|
||||||
<template #group>
|
<DataTableEmpty v-else type="nodata" />
|
||||||
<div style="min-height: 60px">
|
</div>
|
||||||
<div style="min-height: 500px">
|
</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"
|
v-for="group in searchGroupResults"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer hover:bg-muted/50 hover:rounded-lg"
|
class="cursor-pointer hover:bg-muted x-hover-list rounded-none"
|
||||||
@click="showGroupDialog(group.id)">
|
@click="showGroupDialog(group.id)">
|
||||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
<ItemMedia variant="image">
|
||||||
<img
|
<Avatar class="rounded-sm">
|
||||||
class="size-full rounded-full object-cover"
|
<AvatarImage :src="getSmallThumbnailUrl(group.iconUrl)" loading="lazy" />
|
||||||
:src="getSmallThumbnailUrl(group.iconUrl)"
|
<AvatarFallback>
|
||||||
loading="lazy" />
|
<Users class="size-5 text-muted-foreground" />
|
||||||
</div>
|
</AvatarFallback>
|
||||||
<div class="flex-1 overflow-hidden">
|
</Avatar>
|
||||||
<span class="block truncate font-medium leading-[18px]">
|
</ItemMedia>
|
||||||
<span v-text="group.name"></span>
|
<ItemContent class="min-w-0">
|
||||||
<span style="margin-left: 6px; font-weight: normal">({{ group.memberCount }})</span>
|
<ItemTitle class="truncate max-w-full">
|
||||||
<span
|
{{ group.name }}
|
||||||
class="text-muted-foreground font-mono text-xs"
|
<span class="font-normal">({{ group.memberCount }})</span>
|
||||||
style="margin-left: 6px; font-weight: normal"
|
<span class="text-muted-foreground font-mono text-xs font-normal">
|
||||||
>{{ group.shortCode }}.{{ group.discriminator }}</span
|
{{ group.shortCode }}.{{ group.discriminator }}
|
||||||
>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="block truncate text-xs" v-text="group.description"></span>
|
</ItemTitle>
|
||||||
</div>
|
<ItemDescription class="truncate text-xs!">
|
||||||
</div>
|
{{ group.description }}
|
||||||
</div>
|
</ItemDescription>
|
||||||
<ButtonGroup v-if="searchGroupResults.length" style="margin-top: 16px">
|
</ItemContent>
|
||||||
<Button
|
</Item>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="!searchGroupParams.offset"
|
|
||||||
@click="moreSearchGroup(-1)">
|
|
||||||
<ArrowLeft />
|
|
||||||
{{ t('view.search.prev_page') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="searchGroupResults.length < 10"
|
|
||||||
@click="moreSearchGroup(1)">
|
|
||||||
<ArrowRight />
|
|
||||||
{{ t('view.search.next_page') }}
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</TabsUnderline>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { ArrowLeft, ArrowRight, RefreshCw, Trash2 } from 'lucide-vue-next';
|
import { Settings, Trash2, User, Users } from 'lucide-vue-next';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { computed, ref } from 'vue';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { ButtonGroup } from '@/components/ui/button-group';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { InputGroupField } from '@/components/ui/input-group';
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { TabsUnderline } from '@/components/ui/tabs';
|
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
@@ -387,54 +326,73 @@
|
|||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
useAvatarProviderStore,
|
useAvatarProviderStore,
|
||||||
useAvatarStore,
|
useSearchStore
|
||||||
useGroupStore,
|
|
||||||
useSearchStore,
|
|
||||||
useUserStore,
|
|
||||||
useWorldStore
|
|
||||||
} from '../../stores';
|
} from '../../stores';
|
||||||
import { convertFileUrlToImageUrl, replaceBioSymbols, userImage } from '../../shared/utils';
|
import { convertFileUrlToImageUrl, languageClass, userImage } from '../../shared/utils';
|
||||||
import { refreshUserDialogAvatars, showUserDialog } from '../../coordinators/userCoordinator';
|
import { showAvatarDialog } from '../../coordinators/avatarCoordinator';
|
||||||
import { groupRequest } from '../../api';
|
import { showGroupDialog } from '../../coordinators/groupCoordinator';
|
||||||
|
import { showUserDialog } from '../../coordinators/userCoordinator';
|
||||||
|
import { showWorldDialog } from '../../coordinators/worldCoordinator';
|
||||||
import { useSearchAvatar } from './composables/useSearchAvatar';
|
import { useSearchAvatar } from './composables/useSearchAvatar';
|
||||||
import { useSearchWorld } from './composables/useSearchWorld';
|
import { useSearchWorld } from './composables/useSearchWorld';
|
||||||
|
import { useSearchUser } from './composables/useSearchUser';
|
||||||
|
import { useSearchGroup } from './composables/useSearchGroup';
|
||||||
|
|
||||||
const { randomUserColours } = storeToRefs(useAppearanceSettingsStore());
|
const { randomUserColours } = storeToRefs(useAppearanceSettingsStore());
|
||||||
const { avatarRemoteDatabaseProviderList, avatarRemoteDatabaseProvider } = storeToRefs(useAvatarProviderStore());
|
const { avatarRemoteDatabaseProviderList, avatarRemoteDatabaseProvider, isAvatarProviderDialogVisible } =
|
||||||
|
storeToRefs(useAvatarProviderStore());
|
||||||
const { setAvatarProvider } = useAvatarProviderStore();
|
const { setAvatarProvider } = useAvatarProviderStore();
|
||||||
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
|
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
|
||||||
const { userDialog } = storeToRefs(useUserStore());
|
|
||||||
|
|
||||||
const { showAvatarDialog } = useAvatarStore();
|
|
||||||
const { showWorldDialog } = useWorldStore();
|
|
||||||
const { showGroupDialog } = useGroupStore();
|
|
||||||
const { searchText, searchUserResults } = storeToRefs(useSearchStore());
|
const { searchText, searchUserResults } = storeToRefs(useSearchStore());
|
||||||
const { clearSearch, moreSearchUser } = useSearchStore();
|
const { clearSearch } = useSearchStore();
|
||||||
const { cachedConfig } = storeToRefs(useAuthStore());
|
const { cachedConfig } = storeToRefs(useAuthStore());
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const activeSearchTab = ref('user');
|
const activeSearchTab = ref('user');
|
||||||
const searchTabs = computed(() => [
|
|
||||||
{ value: 'user', label: t('view.search.user.header') },
|
// Keyboard shortcuts: Alt+Left (prev page) / Alt+Right (next page)
|
||||||
{ value: 'world', label: t('view.search.world.header') },
|
const keys = useMagicKeys();
|
||||||
{ value: 'avatar', label: t('view.search.avatar.header') },
|
const stopPrevWatch = whenever(keys['Alt+ArrowLeft'], () => {
|
||||||
{ value: 'group', label: t('view.search.group.header') }
|
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 {
|
const {
|
||||||
searchAvatarFilter,
|
|
||||||
searchAvatarSort,
|
|
||||||
searchAvatarFilterRemote,
|
|
||||||
searchAvatarPageNum,
|
searchAvatarPageNum,
|
||||||
searchAvatarResults,
|
searchAvatarResults,
|
||||||
searchAvatarPage,
|
searchAvatarPage,
|
||||||
isSearchAvatarLoading,
|
isSearchAvatarLoading,
|
||||||
searchAvatar,
|
searchAvatar,
|
||||||
moreSearchAvatar,
|
moreSearchAvatar,
|
||||||
handleSearchAvatarFilterChange,
|
|
||||||
handleSearchAvatarFilterRemoteChange,
|
|
||||||
handleSearchAvatarSortChange,
|
|
||||||
clearAvatarSearch
|
clearAvatarSearch
|
||||||
} = useSearchAvatar();
|
} = useSearchAvatar();
|
||||||
|
|
||||||
@@ -450,20 +408,56 @@
|
|||||||
clearWorldSearch
|
clearWorldSearch
|
||||||
} = useSearchWorld();
|
} = useSearchWorld();
|
||||||
|
|
||||||
const searchUserParams = ref({});
|
const {
|
||||||
const searchUserByBio = ref(false);
|
searchGroupParams,
|
||||||
const searchUserSortByLastLoggedIn = ref(false);
|
searchGroupResults,
|
||||||
|
isSearchGroupLoading,
|
||||||
|
searchGroup,
|
||||||
|
moreSearchGroup,
|
||||||
|
clearGroupSearch
|
||||||
|
} = useSearchGroup();
|
||||||
|
|
||||||
const isSearchUserLoading = ref(false);
|
const paginationConfig = computed(() => {
|
||||||
const isSearchGroupLoading = ref(false);
|
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: () => {} };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const searchGroupParams = ref({});
|
|
||||||
const searchGroupResults = ref([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
function getSmallThumbnailUrl(url) {
|
function getSmallThumbnailUrl(url) {
|
||||||
return convertFileUrlToImageUrl(url);
|
return convertFileUrlToImageUrl(url);
|
||||||
}
|
}
|
||||||
@@ -472,11 +466,10 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function handleClearSearch() {
|
function handleClearSearch() {
|
||||||
searchUserParams.value = {};
|
clearUserSearch();
|
||||||
clearWorldSearch();
|
clearWorldSearch();
|
||||||
clearAvatarSearch();
|
clearAvatarSearch();
|
||||||
searchGroupParams.value = {};
|
clearGroupSearch();
|
||||||
searchGroupResults.value = [];
|
|
||||||
clearSearch();
|
clearSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,19 +481,14 @@
|
|||||||
searchText.value = text;
|
searchText.value = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param tabName
|
|
||||||
*/
|
|
||||||
function handleSearchTabChange(tabName) {
|
|
||||||
searchText.value = '';
|
|
||||||
activeSearchTab.value = tabName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function search() {
|
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) {
|
switch (activeSearchTab.value) {
|
||||||
case 'user':
|
case 'user':
|
||||||
searchUser();
|
searchUser();
|
||||||
@@ -516,67 +504,4 @@
|
|||||||
break;
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param go
|
|
||||||
*/
|
|
||||||
async function handleMoreSearchUser(go = null) {
|
|
||||||
isSearchUserLoading.value = true;
|
|
||||||
await moreSearchUser(go, searchUserParams.value);
|
|
||||||
isSearchUserLoading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
async function searchGroup() {
|
|
||||||
searchGroupParams.value = {
|
|
||||||
n: 10,
|
|
||||||
offset: 0,
|
|
||||||
query: replaceBioSymbols(searchText.value)
|
|
||||||
};
|
|
||||||
await moreSearchGroup();
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param go
|
|
||||||
*/
|
|
||||||
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>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
randomUserColours: ref(false),
|
||||||
|
avatarRemoteDatabaseProviderList: ref(['provider-a']),
|
||||||
|
avatarRemoteDatabaseProvider: ref('provider-a'),
|
||||||
|
isAvatarProviderDialogVisible: ref(false),
|
||||||
|
avatarRemoteDatabase: ref(true),
|
||||||
|
searchText: ref(''),
|
||||||
|
searchUserResults: ref([]),
|
||||||
|
cachedConfig: ref({ dynamicWorldRows: [] }),
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
setAvatarProvider: vi.fn(),
|
||||||
|
showAvatarDialog: vi.fn(),
|
||||||
|
showGroupDialog: vi.fn(),
|
||||||
|
showUserDialog: vi.fn(),
|
||||||
|
showWorldDialog: vi.fn(),
|
||||||
|
toastWarning: vi.fn(),
|
||||||
|
useSearchUserApi: null,
|
||||||
|
useSearchAvatarApi: null,
|
||||||
|
useSearchWorldApi: null,
|
||||||
|
useSearchGroupApi: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.useSearchUserApi = {
|
||||||
|
searchUserParams: ref({ offset: 0 }),
|
||||||
|
searchUserByBio: ref(false),
|
||||||
|
searchUserSortByLastLoggedIn: ref(false),
|
||||||
|
isSearchUserLoading: ref(false),
|
||||||
|
searchUser: vi.fn(),
|
||||||
|
handleMoreSearchUser: vi.fn(),
|
||||||
|
clearUserSearch: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mocks.useSearchAvatarApi = {
|
||||||
|
searchAvatarPageNum: ref(0),
|
||||||
|
searchAvatarResults: ref([]),
|
||||||
|
searchAvatarPage: ref([]),
|
||||||
|
isSearchAvatarLoading: ref(false),
|
||||||
|
searchAvatar: vi.fn(),
|
||||||
|
moreSearchAvatar: vi.fn(),
|
||||||
|
clearAvatarSearch: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mocks.useSearchWorldApi = {
|
||||||
|
searchWorldLabs: ref(false),
|
||||||
|
searchWorldParams: ref({ offset: 0 }),
|
||||||
|
searchWorldCategoryIndex: ref(null),
|
||||||
|
searchWorldResults: ref([]),
|
||||||
|
isSearchWorldLoading: ref(false),
|
||||||
|
searchWorld: vi.fn(),
|
||||||
|
moreSearchWorld: vi.fn(),
|
||||||
|
handleSearchWorldCategorySelect: vi.fn(),
|
||||||
|
clearWorldSearch: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mocks.useSearchGroupApi = {
|
||||||
|
searchGroupParams: ref({ offset: 0 }),
|
||||||
|
searchGroupResults: ref([]),
|
||||||
|
isSearchGroupLoading: ref(false),
|
||||||
|
searchGroup: vi.fn(),
|
||||||
|
moreSearchGroup: vi.fn(),
|
||||||
|
clearGroupSearch: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('pinia', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
storeToRefs: (store) => store
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key) => key
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('vue-sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
warning: (...args) => mocks.toastWarning(...args)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useMagicKeys: () => ({}),
|
||||||
|
whenever: () => vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../stores', () => ({
|
||||||
|
useAppearanceSettingsStore: () => ({
|
||||||
|
randomUserColours: mocks.randomUserColours
|
||||||
|
}),
|
||||||
|
useAvatarProviderStore: () => ({
|
||||||
|
avatarRemoteDatabaseProviderList: mocks.avatarRemoteDatabaseProviderList,
|
||||||
|
avatarRemoteDatabaseProvider: mocks.avatarRemoteDatabaseProvider,
|
||||||
|
isAvatarProviderDialogVisible: mocks.isAvatarProviderDialogVisible,
|
||||||
|
setAvatarProvider: (...args) => mocks.setAvatarProvider(...args)
|
||||||
|
}),
|
||||||
|
useAdvancedSettingsStore: () => ({
|
||||||
|
avatarRemoteDatabase: mocks.avatarRemoteDatabase
|
||||||
|
}),
|
||||||
|
useSearchStore: () => ({
|
||||||
|
searchText: mocks.searchText,
|
||||||
|
searchUserResults: mocks.searchUserResults,
|
||||||
|
clearSearch: (...args) => mocks.clearSearch(...args)
|
||||||
|
}),
|
||||||
|
useAuthStore: () => ({
|
||||||
|
cachedConfig: mocks.cachedConfig
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../composables/useSearchUser', () => ({
|
||||||
|
useSearchUser: () => mocks.useSearchUserApi
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../composables/useSearchAvatar', () => ({
|
||||||
|
useSearchAvatar: () => mocks.useSearchAvatarApi
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../composables/useSearchWorld', () => ({
|
||||||
|
useSearchWorld: () => mocks.useSearchWorldApi
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../composables/useSearchGroup', () => ({
|
||||||
|
useSearchGroup: () => mocks.useSearchGroupApi
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../coordinators/avatarCoordinator', () => ({
|
||||||
|
showAvatarDialog: (...args) => mocks.showAvatarDialog(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../coordinators/groupCoordinator', () => ({
|
||||||
|
showGroupDialog: (...args) => mocks.showGroupDialog(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../coordinators/userCoordinator', () => ({
|
||||||
|
showUserDialog: (...args) => mocks.showUserDialog(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../coordinators/worldCoordinator', () => ({
|
||||||
|
showWorldDialog: (...args) => mocks.showWorldDialog(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../shared/utils', () => ({
|
||||||
|
convertFileUrlToImageUrl: (url) => url,
|
||||||
|
languageClass: (lang) => `lang-${lang}`,
|
||||||
|
userImage: () => 'https://example.com/u.png'
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tabs', () => ({
|
||||||
|
Tabs: {
|
||||||
|
props: ['modelValue'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template:
|
||||||
|
'<div data-testid="tabs">' +
|
||||||
|
'<button data-testid="set-tab-user" @click="$emit(\'update:modelValue\', \'user\')" />' +
|
||||||
|
'<button data-testid="set-tab-avatar" @click="$emit(\'update:modelValue\', \'avatar\')" />' +
|
||||||
|
'<button data-testid="set-tab-group" @click="$emit(\'update:modelValue\', \'group\')" />' +
|
||||||
|
'<slot />' +
|
||||||
|
'</div>'
|
||||||
|
},
|
||||||
|
TabsList: { template: '<div><slot /></div>' },
|
||||||
|
TabsTrigger: { props: ['value'], template: '<button :data-value="value"><slot /></button>' },
|
||||||
|
TabsContent: { props: ['value'], template: '<section :data-testid="`content-${value}`"><slot /></section>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/button', () => ({
|
||||||
|
Button: {
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button data-testid="button" v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/checkbox', () => ({
|
||||||
|
Checkbox: {
|
||||||
|
props: ['modelValue'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template:
|
||||||
|
'<input type="checkbox" data-testid="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/input-group', () => ({
|
||||||
|
InputGroupField: {
|
||||||
|
props: ['modelValue', 'placeholder'],
|
||||||
|
emits: ['input', 'keyup.enter'],
|
||||||
|
template:
|
||||||
|
'<input data-testid="search-input" :value="modelValue" :placeholder="placeholder" @input="$emit(\'input\', $event.target.value)" @keyup.enter="$emit(\'keyup.enter\')" />'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/select', () => ({
|
||||||
|
Select: { template: '<div><slot /></div>' },
|
||||||
|
SelectContent: { template: '<div><slot /></div>' },
|
||||||
|
SelectGroup: { template: '<div><slot /></div>' },
|
||||||
|
SelectItem: { template: '<div><slot /></div>' },
|
||||||
|
SelectTrigger: { template: '<div><slot /></div>' },
|
||||||
|
SelectValue: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/item', () => ({
|
||||||
|
Item: {
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<article class="item" @click="$emit(\'click\')"><slot /></article>'
|
||||||
|
},
|
||||||
|
ItemGroup: { template: '<div><slot /></div>' },
|
||||||
|
ItemHeader: { template: '<div><slot /></div>' },
|
||||||
|
ItemMedia: { template: '<div><slot /></div>' },
|
||||||
|
ItemContent: { template: '<div><slot /></div>' },
|
||||||
|
ItemTitle: { template: '<div><slot /></div>' },
|
||||||
|
ItemDescription: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/avatar', () => ({
|
||||||
|
Avatar: { template: '<div><slot /></div>' },
|
||||||
|
AvatarImage: { template: '<img />' },
|
||||||
|
AvatarFallback: { template: '<span><slot /></span>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/data-table', () => ({
|
||||||
|
DataTableEmpty: { template: '<div data-testid="empty">empty</div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/spinner', () => ({
|
||||||
|
Spinner: { template: '<i data-testid="spinner" />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('lucide-vue-next', () => ({
|
||||||
|
Settings: { template: '<i />' },
|
||||||
|
Trash2: { template: '<i />' },
|
||||||
|
User: { template: '<i />' },
|
||||||
|
Users: { template: '<i />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import SearchView from '../Search.vue';
|
||||||
|
|
||||||
|
function mountSearch() {
|
||||||
|
return mount(SearchView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
TooltipWrapper: { template: '<div><slot /></div>' },
|
||||||
|
AvatarProviderDialog: { template: '<div data-testid="avatar-provider-dialog" />' },
|
||||||
|
SearchPagination: {
|
||||||
|
props: ['show', 'prevDisabled', 'nextDisabled'],
|
||||||
|
emits: ['prev', 'next'],
|
||||||
|
template: '<div data-testid="pagination" />'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Search.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.searchText.value = '';
|
||||||
|
mocks.searchUserResults.value = [];
|
||||||
|
mocks.randomUserColours.value = false;
|
||||||
|
|
||||||
|
mocks.useSearchUserApi.searchUserParams.value = { offset: 0 };
|
||||||
|
mocks.useSearchUserApi.searchUserByBio.value = false;
|
||||||
|
mocks.useSearchUserApi.searchUserSortByLastLoggedIn.value = false;
|
||||||
|
mocks.useSearchUserApi.isSearchUserLoading.value = false;
|
||||||
|
|
||||||
|
mocks.useSearchAvatarApi.searchAvatarPageNum.value = 0;
|
||||||
|
mocks.useSearchAvatarApi.searchAvatarResults.value = [];
|
||||||
|
mocks.useSearchAvatarApi.searchAvatarPage.value = [];
|
||||||
|
mocks.useSearchAvatarApi.isSearchAvatarLoading.value = false;
|
||||||
|
|
||||||
|
mocks.useSearchWorldApi.searchWorldParams.value = { offset: 0 };
|
||||||
|
mocks.useSearchWorldApi.searchWorldResults.value = [];
|
||||||
|
mocks.useSearchWorldApi.isSearchWorldLoading.value = false;
|
||||||
|
|
||||||
|
mocks.useSearchGroupApi.searchGroupParams.value = { offset: 0 };
|
||||||
|
mocks.useSearchGroupApi.searchGroupResults.value = [];
|
||||||
|
mocks.useSearchGroupApi.isSearchGroupLoading.value = false;
|
||||||
|
|
||||||
|
mocks.clearSearch.mockReset();
|
||||||
|
mocks.toastWarning.mockReset();
|
||||||
|
mocks.showUserDialog.mockReset();
|
||||||
|
mocks.showGroupDialog.mockReset();
|
||||||
|
|
||||||
|
mocks.useSearchUserApi.searchUser.mockReset();
|
||||||
|
mocks.useSearchUserApi.clearUserSearch.mockReset();
|
||||||
|
mocks.useSearchAvatarApi.searchAvatar.mockReset();
|
||||||
|
mocks.useSearchAvatarApi.clearAvatarSearch.mockReset();
|
||||||
|
mocks.useSearchWorldApi.searchWorld.mockReset();
|
||||||
|
mocks.useSearchWorldApi.clearWorldSearch.mockReset();
|
||||||
|
mocks.useSearchGroupApi.searchGroup.mockReset();
|
||||||
|
mocks.useSearchGroupApi.clearGroupSearch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all tab searches from toolbar clear button', async () => {
|
||||||
|
const wrapper = mountSearch();
|
||||||
|
|
||||||
|
await wrapper.get('button.ml-2').trigger('click');
|
||||||
|
|
||||||
|
expect(mocks.useSearchUserApi.clearUserSearch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.useSearchWorldApi.clearWorldSearch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.useSearchAvatarApi.clearAvatarSearch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.useSearchGroupApi.clearGroupSearch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.clearSearch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs user search on Enter when active tab is user', async () => {
|
||||||
|
const wrapper = mountSearch();
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="search-input"]').trigger('keyup.enter');
|
||||||
|
|
||||||
|
expect(mocks.useSearchUserApi.searchUser).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.useSearchAvatarApi.searchAvatar).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows avatar minimum length warning and skips avatar search', async () => {
|
||||||
|
const wrapper = mountSearch();
|
||||||
|
mocks.searchText.value = 'ab';
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="set-tab-avatar"]').trigger('click');
|
||||||
|
await wrapper.get('[data-testid="search-input"]').trigger('keyup.enter');
|
||||||
|
|
||||||
|
expect(mocks.toastWarning).toHaveBeenCalledWith('view.search.avatar.min_chars_warning');
|
||||||
|
expect(mocks.useSearchAvatarApi.searchAvatar).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens user dialog when clicking a user item', async () => {
|
||||||
|
mocks.searchUserResults.value = [
|
||||||
|
{
|
||||||
|
id: 'usr_1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
bio: 'Hi',
|
||||||
|
$trustLevel: 'Known User',
|
||||||
|
$trustClass: 'text-green',
|
||||||
|
$userColour: '#fff',
|
||||||
|
$languages: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = mountSearch();
|
||||||
|
|
||||||
|
await wrapper.find('.item').trigger('click');
|
||||||
|
|
||||||
|
expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens group dialog when clicking a group item', async () => {
|
||||||
|
mocks.useSearchGroupApi.searchGroupResults.value = [
|
||||||
|
{
|
||||||
|
id: 'grp_1',
|
||||||
|
iconUrl: 'https://example.com/group.png',
|
||||||
|
name: 'Group One',
|
||||||
|
memberCount: 12,
|
||||||
|
shortCode: 'AB',
|
||||||
|
discriminator: '1234',
|
||||||
|
description: 'desc'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = mountSearch();
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="set-tab-group"]').trigger('click');
|
||||||
|
const items = wrapper.findAll('.item');
|
||||||
|
await items[items.length - 1].trigger('click');
|
||||||
|
|
||||||
|
expect(mocks.showGroupDialog).toHaveBeenCalledWith('grp_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="show" class="shrink-0 flex items-center justify-center h-[60px]">
|
||||||
|
<ButtonGroup class="shadow-lg rounded-lg">
|
||||||
|
<Button variant="outline" size="sm" :disabled="prevDisabled" @click="$emit('prev')">
|
||||||
|
<ArrowLeft />
|
||||||
|
{{ t('view.search.prev_page') }}
|
||||||
|
<Kbd class="ml-1">{{ isMac ? '⌥' : 'Alt' }}</Kbd>
|
||||||
|
<Kbd>←</Kbd>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" :disabled="nextDisabled" @click="$emit('next')">
|
||||||
|
{{ t('view.search.next_page') }}
|
||||||
|
<Kbd class="ml-1">{{ isMac ? '⌥' : 'Alt' }}</Kbd>
|
||||||
|
<Kbd>→</Kbd>
|
||||||
|
<ArrowRight />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ButtonGroup } from '@/components/ui/button-group';
|
||||||
|
import { Kbd } from '@/components/ui/kbd';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
prevDisabled: { type: Boolean, default: true },
|
||||||
|
nextDisabled: { type: Boolean, default: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['prev', 'next']);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
avatarRemoteDatabase: require('vue').ref(true),
|
||||||
|
searchText: require('vue').ref(''),
|
||||||
|
lookupAvatars: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('pinia', () => ({
|
||||||
|
storeToRefs: (store) => store
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../stores', () => ({
|
||||||
|
useAdvancedSettingsStore: () => ({
|
||||||
|
avatarRemoteDatabase: mocks.avatarRemoteDatabase
|
||||||
|
}),
|
||||||
|
useSearchStore: () => ({
|
||||||
|
searchText: mocks.searchText
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../coordinators/avatarCoordinator', () => ({
|
||||||
|
lookupAvatars: (...args) => mocks.lookupAvatars(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useSearchAvatar } from '../useSearchAvatar';
|
||||||
|
|
||||||
|
describe('useSearchAvatar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.avatarRemoteDatabase.value = true;
|
||||||
|
mocks.searchText.value = '';
|
||||||
|
mocks.lookupAvatars.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries remote avatars and builds first page', async () => {
|
||||||
|
mocks.searchText.value = 'alice';
|
||||||
|
mocks.lookupAvatars.mockResolvedValue([
|
||||||
|
{ id: 'avtr_1', name: 'A' },
|
||||||
|
{ id: 'avtr_1', name: 'A-dup' },
|
||||||
|
{ id: 'avtr_2', name: 'B' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const api = useSearchAvatar();
|
||||||
|
await api.searchAvatar();
|
||||||
|
|
||||||
|
expect(mocks.lookupAvatars).toHaveBeenCalledWith('search', 'alice');
|
||||||
|
expect(api.searchAvatarResults.value.map((x) => x.id)).toEqual(['avtr_1', 'avtr_2']);
|
||||||
|
expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(['avtr_1', 'avtr_2']);
|
||||||
|
expect(api.searchAvatarPageNum.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips remote query when text is too short', async () => {
|
||||||
|
mocks.searchText.value = 'ab';
|
||||||
|
const api = useSearchAvatar();
|
||||||
|
|
||||||
|
await api.searchAvatar();
|
||||||
|
|
||||||
|
expect(mocks.lookupAvatars).not.toHaveBeenCalled();
|
||||||
|
expect(api.searchAvatarResults.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates results by 10 items', () => {
|
||||||
|
const api = useSearchAvatar();
|
||||||
|
api.searchAvatarResults.value = Array.from({ length: 25 }, (_, i) => ({ id: `avtr_${i}` }));
|
||||||
|
api.searchAvatarPage.value = api.searchAvatarResults.value.slice(0, 10);
|
||||||
|
|
||||||
|
api.moreSearchAvatar(1);
|
||||||
|
expect(api.searchAvatarPageNum.value).toBe(1);
|
||||||
|
expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(
|
||||||
|
Array.from({ length: 10 }, (_, i) => `avtr_${i + 10}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
api.moreSearchAvatar(-1);
|
||||||
|
expect(api.searchAvatarPageNum.value).toBe(0);
|
||||||
|
expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(
|
||||||
|
Array.from({ length: 10 }, (_, i) => `avtr_${i}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
searchText: require('vue').ref(''),
|
||||||
|
replaceBioSymbols: vi.fn((text) => text),
|
||||||
|
groupSearch: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('pinia', () => ({
|
||||||
|
storeToRefs: (store) => store
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../stores', () => ({
|
||||||
|
useSearchStore: () => ({
|
||||||
|
searchText: mocks.searchText
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../shared/utils', () => ({
|
||||||
|
replaceBioSymbols: (...args) => mocks.replaceBioSymbols(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
groupRequest: {
|
||||||
|
groupSearch: (...args) => mocks.groupSearch(...args)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useSearchGroup } from '../useSearchGroup';
|
||||||
|
|
||||||
|
describe('useSearchGroup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.searchText.value = '';
|
||||||
|
mocks.replaceBioSymbols.mockReset();
|
||||||
|
mocks.replaceBioSymbols.mockImplementation((text) => text);
|
||||||
|
mocks.groupSearch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts group search with normalized query', async () => {
|
||||||
|
mocks.searchText.value = 'group+name';
|
||||||
|
mocks.replaceBioSymbols.mockReturnValue('group name');
|
||||||
|
mocks.groupSearch.mockResolvedValue({ json: [{ id: 'grp_1' }, { id: 'grp_1' }, { id: 'grp_2' }] });
|
||||||
|
|
||||||
|
const api = useSearchGroup();
|
||||||
|
await api.searchGroup();
|
||||||
|
|
||||||
|
expect(mocks.replaceBioSymbols).toHaveBeenCalledWith('group+name');
|
||||||
|
expect(mocks.groupSearch).toHaveBeenCalledWith({
|
||||||
|
n: 10,
|
||||||
|
offset: 0,
|
||||||
|
query: 'group name'
|
||||||
|
});
|
||||||
|
expect(api.searchGroupResults.value.map((x) => x.id)).toEqual(['grp_1', 'grp_2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves backward paging offset without going below zero', async () => {
|
||||||
|
mocks.groupSearch.mockResolvedValue({ json: [] });
|
||||||
|
const api = useSearchGroup();
|
||||||
|
api.searchGroupParams.value = { n: 10, offset: 5, query: 'abc' };
|
||||||
|
|
||||||
|
await api.moreSearchGroup(-1);
|
||||||
|
|
||||||
|
expect(mocks.groupSearch).toHaveBeenCalledWith({ n: 10, offset: 0, query: 'abc' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
searchText: require('vue').ref(''),
|
||||||
|
moreSearchUser: vi.fn(() => Promise.resolve())
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('pinia', () => ({
|
||||||
|
storeToRefs: (store) => store
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../stores', () => ({
|
||||||
|
useSearchStore: () => ({
|
||||||
|
searchText: mocks.searchText,
|
||||||
|
moreSearchUser: (...args) => mocks.moreSearchUser(...args)
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useSearchUser } from '../useSearchUser';
|
||||||
|
|
||||||
|
describe('useSearchUser', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.searchText.value = '';
|
||||||
|
mocks.moreSearchUser.mockReset();
|
||||||
|
mocks.moreSearchUser.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds search params and requests first page', async () => {
|
||||||
|
mocks.searchText.value = 'Alice';
|
||||||
|
const api = useSearchUser();
|
||||||
|
api.searchUserByBio.value = true;
|
||||||
|
api.searchUserSortByLastLoggedIn.value = true;
|
||||||
|
|
||||||
|
await api.searchUser();
|
||||||
|
|
||||||
|
expect(mocks.moreSearchUser).toHaveBeenCalledWith(null, {
|
||||||
|
n: 10,
|
||||||
|
offset: 0,
|
||||||
|
search: 'Alice',
|
||||||
|
customFields: 'bio',
|
||||||
|
sort: 'last_login'
|
||||||
|
});
|
||||||
|
expect(api.isSearchUserLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes page direction into handleMoreSearchUser', async () => {
|
||||||
|
const api = useSearchUser();
|
||||||
|
api.searchUserParams.value = { n: 10, offset: 10, search: 'Alice', customFields: 'displayName', sort: 'relevance' };
|
||||||
|
|
||||||
|
await api.handleMoreSearchUser(-1);
|
||||||
|
|
||||||
|
expect(mocks.moreSearchUser).toHaveBeenCalledWith(-1, api.searchUserParams.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
searchText: require('vue').ref(''),
|
||||||
|
cachedConfig: require('vue').ref({ dynamicWorldRows: [] }),
|
||||||
|
cachedWorlds: new Map(),
|
||||||
|
replaceBioSymbols: vi.fn((text) => text),
|
||||||
|
getWorlds: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('pinia', () => ({
|
||||||
|
storeToRefs: (store) => store
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../stores', () => ({
|
||||||
|
useSearchStore: () => ({
|
||||||
|
searchText: mocks.searchText
|
||||||
|
}),
|
||||||
|
useAuthStore: () => ({
|
||||||
|
cachedConfig: mocks.cachedConfig
|
||||||
|
}),
|
||||||
|
useWorldStore: () => ({
|
||||||
|
cachedWorlds: mocks.cachedWorlds
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../shared/utils', () => ({
|
||||||
|
replaceBioSymbols: (...args) => mocks.replaceBioSymbols(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
worldRequest: {
|
||||||
|
getWorlds: (...args) => mocks.getWorlds(...args)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useSearchWorld } from '../useSearchWorld';
|
||||||
|
|
||||||
|
describe('useSearchWorld', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.searchText.value = '';
|
||||||
|
mocks.cachedConfig.value = { dynamicWorldRows: [] };
|
||||||
|
mocks.cachedWorlds.clear();
|
||||||
|
mocks.replaceBioSymbols.mockReset();
|
||||||
|
mocks.replaceBioSymbols.mockImplementation((text) => text);
|
||||||
|
mocks.getWorlds.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates relevance params and appends system_approved tag', async () => {
|
||||||
|
mocks.searchText.value = 'home world';
|
||||||
|
mocks.replaceBioSymbols.mockReturnValue('home world');
|
||||||
|
mocks.cachedWorlds.set('wrld_1', { id: 'wrld_1', name: 'World One' });
|
||||||
|
mocks.getWorlds.mockResolvedValue({ json: [{ id: 'wrld_1' }, { id: 'wrld_missing' }] });
|
||||||
|
|
||||||
|
const api = useSearchWorld();
|
||||||
|
api.searchWorld({});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mocks.getWorlds).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
n: 10,
|
||||||
|
offset: 0,
|
||||||
|
sort: 'relevance',
|
||||||
|
search: 'home world',
|
||||||
|
order: 'descending',
|
||||||
|
tag: 'system_approved'
|
||||||
|
},
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(api.searchWorldParams.value.search).toBe('home world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects category row and uses row sort settings', async () => {
|
||||||
|
mocks.cachedConfig.value = {
|
||||||
|
dynamicWorldRows: [{ index: 2, sortHeading: 'featured', sortOrder: 'ascending', tag: 'party' }]
|
||||||
|
};
|
||||||
|
mocks.getWorlds.mockResolvedValue({ json: [] });
|
||||||
|
|
||||||
|
const api = useSearchWorld();
|
||||||
|
api.handleSearchWorldCategorySelect(2);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(api.searchWorldCategoryIndex.value).toBe(2);
|
||||||
|
expect(mocks.getWorlds).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
n: 10,
|
||||||
|
offset: 0,
|
||||||
|
sort: 'order',
|
||||||
|
featured: 'true',
|
||||||
|
order: 'ascending',
|
||||||
|
tag: 'party,system_approved'
|
||||||
|
},
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
import {
|
import { useAdvancedSettingsStore, useSearchStore } from '../../../stores';
|
||||||
compareByCreatedAt,
|
import { lookupAvatars } from '../../../coordinators/avatarCoordinator';
|
||||||
compareByName,
|
|
||||||
compareByUpdatedAt
|
|
||||||
} from '../../../shared/utils';
|
|
||||||
import {
|
|
||||||
useAdvancedSettingsStore,
|
|
||||||
useAvatarStore,
|
|
||||||
useSearchStore
|
|
||||||
} from '../../../stores';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avatar search composable for Search view.
|
* Avatar search composable for Search view.
|
||||||
* Manages avatar search state, local/remote filtering, sorting, and pagination.
|
* Searches remote avatar databases only (local avatar browsing is handled by My Avatars page).
|
||||||
*/
|
*/
|
||||||
export function useSearchAvatar() {
|
export function useSearchAvatar() {
|
||||||
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
|
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
|
||||||
const { lookupAvatars, cachedAvatars } = useAvatarStore();
|
|
||||||
const { searchText } = storeToRefs(useSearchStore());
|
const { searchText } = storeToRefs(useSearchStore());
|
||||||
|
|
||||||
const searchAvatarFilter = ref('');
|
|
||||||
const searchAvatarSort = ref('');
|
|
||||||
const searchAvatarFilterRemote = ref('');
|
|
||||||
const searchAvatarPageNum = ref(0);
|
const searchAvatarPageNum = ref(0);
|
||||||
const searchAvatarResults = ref([]);
|
const searchAvatarResults = ref([]);
|
||||||
const searchAvatarPage = ref([]);
|
const searchAvatarPage = ref([]);
|
||||||
@@ -33,84 +21,11 @@ export function useSearchAvatar() {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
async function searchAvatar() {
|
async function searchAvatar() {
|
||||||
let ref;
|
|
||||||
isSearchAvatarLoading.value = true;
|
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 avatars = new Map();
|
||||||
const query = searchText.value;
|
const query = searchText.value;
|
||||||
const queryUpper = query.toUpperCase();
|
|
||||||
if (!query) {
|
if (query && query.length >= 3 && avatarRemoteDatabase.value) {
|
||||||
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);
|
const data = await lookupAvatars('search', query);
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
data.forEach((avatar) => {
|
data.forEach((avatar) => {
|
||||||
@@ -118,26 +33,14 @@ export function useSearchAvatar() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSearchAvatarLoading.value = false;
|
isSearchAvatarLoading.value = false;
|
||||||
}
|
|
||||||
const avatarsArray = Array.from(avatars.values());
|
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;
|
searchAvatarPageNum.value = 0;
|
||||||
searchAvatarResults.value = avatarsArray;
|
searchAvatarResults.value = avatarsArray;
|
||||||
searchAvatarPage.value = avatarsArray.slice(0, 10);
|
searchAvatarPage.value = avatarsArray.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param n
|
* @param n
|
||||||
@@ -158,33 +61,6 @@ export function useSearchAvatar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param value
|
|
||||||
*/
|
|
||||||
function handleSearchAvatarFilterChange(value) {
|
|
||||||
searchAvatarFilter.value = value;
|
|
||||||
searchAvatar();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param value
|
|
||||||
*/
|
|
||||||
function handleSearchAvatarFilterRemoteChange(value) {
|
|
||||||
searchAvatarFilterRemote.value = value;
|
|
||||||
searchAvatar();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param value
|
|
||||||
*/
|
|
||||||
function handleSearchAvatarSortChange(value) {
|
|
||||||
searchAvatarSort.value = value;
|
|
||||||
searchAvatar();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -195,18 +71,12 @@ export function useSearchAvatar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchAvatarFilter,
|
|
||||||
searchAvatarSort,
|
|
||||||
searchAvatarFilterRemote,
|
|
||||||
searchAvatarPageNum,
|
searchAvatarPageNum,
|
||||||
searchAvatarResults,
|
searchAvatarResults,
|
||||||
searchAvatarPage,
|
searchAvatarPage,
|
||||||
isSearchAvatarLoading,
|
isSearchAvatarLoading,
|
||||||
searchAvatar,
|
searchAvatar,
|
||||||
moreSearchAvatar,
|
moreSearchAvatar,
|
||||||
handleSearchAvatarFilterChange,
|
|
||||||
handleSearchAvatarFilterRemoteChange,
|
|
||||||
handleSearchAvatarSortChange,
|
|
||||||
clearAvatarSearch
|
clearAvatarSearch
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
import { useSearchStore } from '../../../stores';
|
||||||
|
import { replaceBioSymbols } from '../../../shared/utils';
|
||||||
|
import { groupRequest } from '../../../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group search composable for Search view.
|
||||||
|
* Manages group search state and pagination.
|
||||||
|
*/
|
||||||
|
export function useSearchGroup() {
|
||||||
|
const { searchText } = storeToRefs(useSearchStore());
|
||||||
|
|
||||||
|
const searchGroupParams = ref({});
|
||||||
|
const searchGroupResults = ref([]);
|
||||||
|
const isSearchGroupLoading = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function searchGroup() {
|
||||||
|
searchGroupParams.value = {
|
||||||
|
n: 10,
|
||||||
|
offset: 0,
|
||||||
|
query: replaceBioSymbols(searchText.value)
|
||||||
|
};
|
||||||
|
await moreSearchGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param go
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function clearGroupSearch() {
|
||||||
|
searchGroupParams.value = {};
|
||||||
|
searchGroupResults.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchGroupParams,
|
||||||
|
searchGroupResults,
|
||||||
|
isSearchGroupLoading,
|
||||||
|
searchGroup,
|
||||||
|
moreSearchGroup,
|
||||||
|
clearGroupSearch
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
import { useSearchStore } from '../../../stores';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User search composable for Search view.
|
||||||
|
* Manages user search state, filters, and pagination.
|
||||||
|
*/
|
||||||
|
export function useSearchUser() {
|
||||||
|
const { searchText } = storeToRefs(useSearchStore());
|
||||||
|
const { moreSearchUser } = useSearchStore();
|
||||||
|
|
||||||
|
const searchUserParams = ref({});
|
||||||
|
const searchUserByBio = ref(false);
|
||||||
|
const searchUserSortByLastLoggedIn = ref(false);
|
||||||
|
const isSearchUserLoading = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param go
|
||||||
|
*/
|
||||||
|
async function handleMoreSearchUser(go = null) {
|
||||||
|
isSearchUserLoading.value = true;
|
||||||
|
await moreSearchUser(go, searchUserParams.value);
|
||||||
|
isSearchUserLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function clearUserSearch() {
|
||||||
|
searchUserParams.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchUserParams,
|
||||||
|
searchUserByBio,
|
||||||
|
searchUserSortByLastLoggedIn,
|
||||||
|
isSearchUserLoading,
|
||||||
|
searchUser,
|
||||||
|
handleMoreSearchUser,
|
||||||
|
clearUserSearch
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</InputGroupAction>
|
</InputGroupAction>
|
||||||
|
|
||||||
<Button size="sm" style="margin-top: 6px" @click="avatarRemoteDatabaseProviderList.push('')">
|
<Button size="sm" style="margin-top: 6px" @click="addProvider">
|
||||||
{{ t('dialog.avatar_database_provider.add_provider') }}
|
{{ t('dialog.avatar_database_provider.add_provider') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,4 +59,11 @@
|
|||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
emit('update:isAvatarProviderDialogVisible', false);
|
emit('update:isAvatarProviderDialogVisible', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function addProvider() {
|
||||||
|
avatarRemoteDatabaseProviderList.value.push('');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user