Fix favs scrolling

This commit is contained in:
Natsumi
2026-02-05 10:50:37 +13:00
committed by pa
parent 66a5a1ff15
commit b4bf4e2567
4 changed files with 1195 additions and 1146 deletions

View File

@@ -1,495 +1,512 @@
<template> <template>
<div class="favorites-page x-container"> <div class="x-container">
<div class="favorites-toolbar"> <div class="favorites-page">
<div> <div class="favorites-toolbar">
<Select :model-value="sortFavorites" @update:modelValue="handleSortFavoritesChange"> <div>
<SelectTrigger size="sm" class="favorites-toolbar__select"> <Select :model-value="sortFavorites" @update:modelValue="handleSortFavoritesChange">
<span class="flex items-center gap-2"> <SelectTrigger size="sm" class="favorites-toolbar__select">
<ArrowUpDown class="h-4 w-4" /> <span class="flex items-center gap-2">
<SelectValue <ArrowUpDown class="h-4 w-4" />
:placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" /> <SelectValue
</span> :placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" />
</SelectTrigger> </span>
<SelectContent> </SelectTrigger>
<SelectGroup> <SelectContent>
<SelectItem <SelectGroup>
:value="false" <SelectItem
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')"> :value="false"
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }} :text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')">
</SelectItem> {{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
<SelectItem </SelectItem>
:value="true" <SelectItem
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')"> :value="true"
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }} :text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')">
</SelectItem> {{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
</SelectGroup> </SelectItem>
</SelectContent> </SelectGroup>
</Select> </SelectContent>
</div> </Select>
<div class="favorites-toolbar__right"> </div>
<InputGroupSearch <div class="favorites-toolbar__right">
v-model="avatarFavoriteSearch" <InputGroupSearch
class="favorites-toolbar__search" v-model="avatarFavoriteSearch"
:placeholder="t('view.favorite.avatars.search')" class="favorites-toolbar__search"
@input="searchAvatarFavorites" /> :placeholder="t('view.favorite.avatars.search')"
<DropdownMenu v-model:open="avatarToolbarMenuOpen"> @input="searchAvatarFavorites" />
<DropdownMenuTrigger as-child> <DropdownMenu v-model:open="avatarToolbarMenuOpen">
<Button class="rounded-full" size="icon-sm" variant="ghost"> <Ellipsis /> </Button> <DropdownMenuTrigger as-child>
</DropdownMenuTrigger> <Button class="rounded-full" size="icon-sm" variant="ghost"> <Ellipsis /> </Button>
<DropdownMenuContent class="favorites-dropdown"> </DropdownMenuTrigger>
<li class="favorites-dropdown__control" @click.stop> <DropdownMenuContent class="favorites-dropdown">
<div class="favorites-dropdown__control-header"> <li class="favorites-dropdown__control" @click.stop>
<span>Scale</span> <div class="favorites-dropdown__control-header">
<span class="favorites-dropdown__control-value">{{ avatarCardScalePercent }}%</span> <span>Scale</span>
</div> <span class="favorites-dropdown__control-value">{{ avatarCardScalePercent }}%</span>
<Slider
v-model="avatarCardScaleValue"
class="favorites-dropdown__slider"
:min="avatarCardScaleSlider.min"
:max="avatarCardScaleSlider.max"
:step="avatarCardScaleSlider.step" />
</li>
<li class="favorites-dropdown__control" @click.stop>
<div class="favorites-dropdown__control-header">
<span>Spacing</span>
<span class="favorites-dropdown__control-value"> {{ avatarCardSpacingPercent }}% </span>
</div>
<Slider
v-model="avatarCardSpacingValue"
class="favorites-dropdown__slider"
:min="avatarCardSpacingSlider.min"
:max="avatarCardSpacingSlider.max"
:step="avatarCardSpacingSlider.step" />
</li>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleAvatarImportClick">
{{ t('view.favorite.import') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleAvatarExportClick">
{{ t('view.favorite.export') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ResizablePanelGroup
ref="avatarSplitterGroupRef"
direction="horizontal"
class="favorites-splitter"
@layout="handleAvatarSplitterLayout">
<ResizablePanel
ref="avatarSplitterPanelRef"
:default-size="avatarSplitterDefaultSize"
:min-size="avatarSplitterMinSize"
:max-size="avatarSplitterMaxSize"
:collapsed-size="0"
collapsible
:order="1">
<div class="favorites-groups-panel">
<div class="group-section">
<div class="group-section__header">
<span>{{ t('view.favorite.avatars.vrchat_favorites') }}</span>
<TooltipWrapper side="bottom" :content="t('view.favorite.refresh_favorites_tooltip')">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="isFavoriteLoading"
@click.stop="handleRefreshFavorites">
<Spinner v-if="isFavoriteLoading" />
<RefreshCw v-else />
</Button>
</TooltipWrapper>
</div>
<div class="group-section__list">
<template v-if="favoriteAvatarGroups.length">
<div
v-for="group in favoriteAvatarGroups"
:key="group.key"
:class="[
'group-item',
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
]"
@click="handleGroupClick('remote', group.key)">
<div class="group-item__top">
<span class="group-item__name">{{ group.displayName }}</span>
<span class="group-item__count">{{ group.count }}/{{ group.capacity }}</span>
</div>
<div class="group-item__bottom">
<Badge variant="outline">
{{ t(`view.favorite.visibility.${group.visibility}`) }}
</Badge>
<DropdownMenu
:open="activeGroupMenu === remoteGroupMenuKey(group.key)"
@update:open="
handleGroupMenuVisible(remoteGroupMenuKey(group.key), $event)
">
<DropdownMenuTrigger asChild>
<Button class="rounded-full" variant="ghost" size="icon-sm" @click.stop>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-55">
<DropdownMenuItem @click="handleRemoteRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.favorite.visibility_tooltip') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent side="right" align="start" class="w-45">
<DropdownMenuCheckboxItem
v-for="visibility in avatarGroupVisibilityOptions"
:key="visibility"
:model-value="group.visibility === visibility"
indicator-position="right"
@select="handleVisibilitySelection(group, visibility)">
<span>{{
t(`view.favorite.visibility.${visibility}`)
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
@click="handleRemoteClear(group)">
<span>{{ t('view.favorite.clear') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
</template> <Slider
<template v-else> v-model="avatarCardScaleValue"
<div class="favorites-dropdown__slider"
v-for="group in avatarGroupPlaceholders" :min="avatarCardScaleSlider.min"
:key="group.key" :max="avatarCardScaleSlider.max"
:class="[ :step="avatarCardScaleSlider.step" />
'group-item', </li>
'group-item--placeholder', <li class="favorites-dropdown__control" @click.stop>
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) } <div class="favorites-dropdown__control-header">
]"> <span>Spacing</span>
<div class="group-item__top"> <span class="favorites-dropdown__control-value">
<span class="group-item__name">{{ group.displayName }}</span> {{ avatarCardSpacingPercent }}%
<span class="group-item__count">--/--</span> </span>
</div>
<div class="group-item__bottom">
<div class="group-item__placeholder-tag"></div>
</div>
</div> </div>
</template> <Slider
</div> v-model="avatarCardSpacingValue"
</div> class="favorites-dropdown__slider"
<div class="group-section"> :min="avatarCardSpacingSlider.min"
<div class="group-section__header"> :max="avatarCardSpacingSlider.max"
<span>{{ t('view.favorite.avatars.local_favorites') }}</span> :step="avatarCardSpacingSlider.step" />
<template v-if="!refreshingLocalFavorites"> </li>
<Button <DropdownMenuSeparator />
class="rounded-full" <DropdownMenuItem @click="handleAvatarImportClick">
size="icon" {{ t('view.favorite.import') }}
variant="outline" </DropdownMenuItem>
@click.stop="refreshLocalAvatarFavorites" <DropdownMenuItem @click="handleAvatarExportClick">
><RefreshCcw {{ t('view.favorite.export') }}
/></Button> </DropdownMenuItem>
</template> </DropdownMenuContent>
<Button size="sm" variant="ghost" v-else @click.stop="cancelLocalAvatarRefresh"> </DropdownMenu>
<Loader /> </div>
</div>
{{ t('view.favorite.avatars.cancel_refresh') }} <ResizablePanelGroup
</Button> ref="avatarSplitterGroupRef"
</div> direction="horizontal"
<div class="group-section__list"> class="favorites-splitter"
<template v-if="localAvatarFavoriteGroups.length"> @layout="handleAvatarSplitterLayout">
<div <ResizablePanel
v-for="group in localAvatarFavoriteGroups" ref="avatarSplitterPanelRef"
:key="group" :default-size="avatarSplitterDefaultSize"
:class="[ :min-size="avatarSplitterMinSize"
'group-item', :max-size="avatarSplitterMaxSize"
{ 'is-active': !hasSearchInput && isGroupActive('local', group) } :collapsed-size="0"
]" collapsible
@click="handleGroupClick('local', group)"> :order="1">
<div class="group-item__top"> <div class="favorites-groups-panel">
<span class="group-item__name">{{ group }}</span> <div class="group-section">
<div class="group-item__right"> <div class="group-section__header">
<span class="group-item__count">{{ <span>{{ t('view.favorite.avatars.vrchat_favorites') }}</span>
localAvatarFavGroupLength(group) <TooltipWrapper side="bottom" :content="t('view.favorite.refresh_favorites_tooltip')">
}}</span> <Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="isFavoriteLoading"
@click.stop="handleRefreshFavorites">
<Spinner v-if="isFavoriteLoading" />
<RefreshCw v-else />
</Button>
</TooltipWrapper>
</div>
<div class="group-section__list">
<template v-if="favoriteAvatarGroups.length">
<div
v-for="group in favoriteAvatarGroups"
:key="group.key"
:class="[
'group-item',
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
]"
@click="handleGroupClick('remote', group.key)">
<div class="group-item__top">
<span class="group-item__name">{{ group.displayName }}</span>
<span class="group-item__count"
>{{ group.count }}/{{ group.capacity }}</span
>
</div>
<div class="group-item__bottom">
<Badge variant="outline">
{{ t(`view.favorite.visibility.${group.visibility}`) }}
</Badge>
<DropdownMenu <DropdownMenu
:open="activeGroupMenu === localGroupMenuKey(group)" :open="activeGroupMenu === remoteGroupMenuKey(group.key)"
@update:open="handleGroupMenuVisible(localGroupMenuKey(group), $event)"> @update:open="
handleGroupMenuVisible(remoteGroupMenuKey(group.key), $event)
">
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
class="rounded-full" class="rounded-full"
size="icon-sm"
variant="ghost" variant="ghost"
@click.stop size="icon-sm"
><Ellipsis @click.stop>
/></Button> <MoreHorizontal />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-50"> <DropdownMenuContent side="right" class="w-55">
<DropdownMenuItem @click="handleLocalRename(group)"> <DropdownMenuItem @click="handleRemoteRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span> <span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="handleCheckInvalidAvatars(group)"> <DropdownMenuSub>
<span>{{ t('view.favorite.avatars.check_invalid') }}</span> <DropdownMenuSubTrigger>
</DropdownMenuItem> <span>{{ t('view.favorite.visibility_tooltip') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
side="right"
align="start"
class="w-45">
<DropdownMenuCheckboxItem
v-for="visibility in avatarGroupVisibilityOptions"
:key="visibility"
:model-value="group.visibility === visibility"
indicator-position="right"
@select="
handleVisibilitySelection(group, visibility)
">
<span>{{
t(`view.favorite.visibility.${visibility}`)
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
@click="handleLocalDelete(group)"> @click="handleRemoteClear(group)">
<span>{{ t('view.favorite.delete_tooltip') }}</span> <span>{{ t('view.favorite.clear') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</div>
</template>
<div v-else class="group-empty">
<DataTableEmpty type="nodata" />
</div>
<TooltipWrapper
v-if="!isCreatingLocalGroup"
:disabled="isLocalUserVrcPlusSupporter"
:content="t('view.favorite.avatars.local_favorites')">
<div
:class="[
'group-item',
'group-item--new',
{ 'is-disabled': !isLocalUserVrcPlusSupporter }
]"
@click="startLocalGroupCreation">
<Plus />
<span>{{ t('view.favorite.avatars.new_group') }}</span>
</div>
</TooltipWrapper>
<InputGroupField
v-else
ref="newLocalGroupInput"
v-model="newLocalGroupName"
size="sm"
class="group-item__input"
:placeholder="t('view.favorite.avatars.new_group')"
@keyup.enter="handleLocalGroupCreationConfirm"
@keyup.esc="cancelLocalGroupCreation"
@blur="cancelLocalGroupCreation" />
</div>
</div>
<div class="group-section">
<div class="group-section__header">
<span>Local History</span>
<DropdownMenu
:open="activeGroupMenu === historyGroupMenuKey"
@update:open="handleGroupMenuVisible(historyGroupMenuKey, $event)">
<DropdownMenuTrigger asChild>
<Button class="rounded-full" size="icon-sm" variant="ghost" @click.stop
><Ellipsis
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-45">
<DropdownMenuItem variant="destructive" @click="handleHistoryClear">
<span>{{ t('view.favorite.clear_tooltip') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="group-section__list">
<div
:class="[
'group-item',
{ 'is-active': !hasSearchInput && isGroupActive('history', historyGroupKey) }
]"
@click="handleGroupClick('history', historyGroupKey)">
<div class="group-item__top">
<span class="group-item__name">Local History</span>
<span class="group-item__count">{{ avatarHistory.length }}/100</span>
</div>
</div>
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle @dragging="setAvatarSplitterDragging" />
<ResizablePanel :order="2">
<div class="favorites-content">
<div class="favorites-content__header">
<div class="favorites-content__title">
<span v-if="isSearchActive">{{ t('view.favorite.avatars.search') }}</span>
<template v-else-if="activeRemoteGroup">
<span>
{{ activeRemoteGroup.displayName }}
<small>{{ activeRemoteGroup.count }}/{{ activeRemoteGroup.capacity }}</small>
</span>
</template>
<template v-else-if="activeLocalGroupName">
<span>
{{ activeLocalGroupName }}
<small>{{ activeLocalGroupCount }}</small>
</span>
</template>
<template v-else-if="isHistorySelected">
<span>
Local History
<small>{{ avatarHistory.length }}/100</small>
</span>
</template>
<span v-else>No Group Selected</span>
</div>
<div class="favorites-content__edit">
<span>{{ t('view.favorite.edit_mode') }}</span>
<Switch v-model="avatarEditMode" :disabled="isSearchActive || !activeRemoteGroup" />
</div>
</div>
<div class="favorites-content__edit-actions">
<div
v-if="avatarEditMode && !isSearchActive && activeRemoteGroup"
class="favorites-content__actions">
<Button size="sm" variant="outline" @click="toggleSelectAllAvatars">
{{
isAllAvatarsSelected
? t('view.favorite.deselect_all')
: t('view.favorite.select_all')
}}
</Button>
<Button
size="sm"
variant="secondary"
:disabled="!hasAvatarSelection"
@click="clearSelectedAvatars">
{{ t('view.favorite.clear') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasAvatarSelection"
@click="copySelectedAvatars">
{{ t('view.favorite.copy') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasAvatarSelection"
@click="showAvatarBulkUnfavoriteSelectionConfirm">
{{ t('view.favorite.bulk_unfavorite') }}
</Button>
</div>
</div>
<div ref="avatarFavoritesContainerRef" class="favorites-content__list">
<template v-if="isSearchActive">
<div class="favorites-content__scroll favorites-content__scroll--native">
<div
v-if="avatarFavoriteSearchResults.length"
class="favorites-search-grid"
:style="avatarFavoritesGridStyle(avatarFavoriteSearchResults.length)">
<div
v-for="favorite in avatarFavoriteSearchResults"
:key="favorite.id"
class="favorites-search-card"
@click="showAvatarDialog(favorite.id)">
<div class="favorites-search-card__content">
<div
class="favorites-search-card__avatar"
:class="{ 'is-empty': !favorite.thumbnailImageUrl }">
<img
v-if="favorite.thumbnailImageUrl"
:src="favorite.thumbnailImageUrl"
loading="lazy" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<span class="name">{{ favorite.name }}</span>
</div>
<span class="text-xs">{{ favorite.authorName }}</span>
</div>
</div>
</div>
</div>
<div v-else class="favorites-empty">
<DataTableEmpty type="nomatch" />
</div>
</div>
</template>
<template v-else-if="activeRemoteGroup">
<div class="favorites-content__scroll favorites-content__scroll--native">
<template v-if="currentRemoteFavorites.length">
<div
class="favorites-card-list"
:style="avatarFavoritesGridStyle(currentRemoteFavorites.length)">
<FavoritesAvatarItem
v-for="favorite in currentRemoteFavorites"
:key="favorite.id"
:favorite="favorite"
:group="activeRemoteGroup"
:selected="selectedFavoriteAvatars.includes(favorite.id)"
:edit-mode="avatarEditMode"
@toggle-select="toggleAvatarSelection(favorite.id, $event)"
@click="showAvatarDialog(favorite.id)" />
</div>
</template> </template>
<div v-else class="favorites-empty"> <template v-else>
<DataTableEmpty type="nodata" />
</div>
</div>
</template>
<template v-else-if="!remoteAvatarGroupsResolved">
<div class="favorites-content__scroll favorites-content__scroll--native">
<div
class="favorites-card-list"
:style="avatarFavoritesGridStyle(avatarGroupPlaceholders.length)">
<div <div
v-for="group in avatarGroupPlaceholders" v-for="group in avatarGroupPlaceholders"
:key="group.key" :key="group.key"
class="favorites-card-placeholder-box"></div> :class="[
</div> 'group-item',
</div> 'group-item--placeholder',
</template> { 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
<template v-else-if="activeLocalGroupName"> ]">
<ScrollArea class="favorites-content__scroll"> <div class="group-item__top">
<template v-if="currentLocalFavorites.length"> <span class="group-item__name">{{ group.displayName }}</span>
<div <span class="group-item__count">--/--</span>
class="favorites-card-list" </div>
:style="avatarFavoritesGridStyle(currentLocalFavorites.length)"> <div class="group-item__bottom">
<FavoritesAvatarItem <div class="group-item__placeholder-tag"></div>
v-for="favorite in currentLocalFavorites" </div>
:key="favorite.id"
:favorite="favorite"
:group="activeLocalGroupName"
is-local-favorite
:edit-mode="avatarEditMode"
@click="showAvatarDialog(favorite.id)" />
</div> </div>
</template> </template>
<div v-else class="favorites-empty"> </div>
<DataTableEmpty type="nodata" /> </div>
</div> <div class="group-section">
</ScrollArea> <div class="group-section__header">
</template> <span>{{ t('view.favorite.avatars.local_favorites') }}</span>
<template v-else-if="isHistorySelected"> <template v-if="!refreshingLocalFavorites">
<div class="favorites-content__scroll favorites-content__scroll--native"> <Button
<template v-if="avatarHistory.length"> class="rounded-full"
size="icon"
variant="outline"
@click.stop="refreshLocalAvatarFavorites"
><RefreshCcw
/></Button>
</template>
<Button size="sm" variant="ghost" v-else @click.stop="cancelLocalAvatarRefresh">
<Loader />
{{ t('view.favorite.avatars.cancel_refresh') }}
</Button>
</div>
<div class="group-section__list">
<template v-if="localAvatarFavoriteGroups.length">
<div <div
class="favorites-card-list" v-for="group in localAvatarFavoriteGroups"
:style="avatarFavoritesGridStyle(avatarHistory.length)"> :key="group"
<FavoritesAvatarLocalHistoryItem :class="[
v-for="favorite in avatarHistory" 'group-item',
:key="favorite.id" { 'is-active': !hasSearchInput && isGroupActive('local', group) }
:favorite="favorite" ]"
@click="showAvatarDialog(favorite.id)" /> @click="handleGroupClick('local', group)">
<div class="group-item__top">
<span class="group-item__name">{{ group }}</span>
<div class="group-item__right">
<span class="group-item__count">{{
localAvatarFavGroupLength(group)
}}</span>
<DropdownMenu
:open="activeGroupMenu === localGroupMenuKey(group)"
@update:open="
handleGroupMenuVisible(localGroupMenuKey(group), $event)
">
<DropdownMenuTrigger asChild>
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click.stop
><Ellipsis
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-50">
<DropdownMenuItem @click="handleLocalRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleCheckInvalidAvatars(group)">
<span>{{ t('view.favorite.avatars.check_invalid') }}</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="handleLocalDelete(group)">
<span>{{ t('view.favorite.delete_tooltip') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div> </div>
</template> </template>
<div v-else class="favorites-empty"> <div v-else class="group-empty">
<DataTableEmpty type="nodata" /> <DataTableEmpty type="nodata" />
</div> </div>
<TooltipWrapper
v-if="!isCreatingLocalGroup"
:disabled="isLocalUserVrcPlusSupporter"
:content="t('view.favorite.avatars.local_favorites')">
<div
:class="[
'group-item',
'group-item--new',
{ 'is-disabled': !isLocalUserVrcPlusSupporter }
]"
@click="startLocalGroupCreation">
<Plus />
<span>{{ t('view.favorite.avatars.new_group') }}</span>
</div>
</TooltipWrapper>
<InputGroupField
v-else
ref="newLocalGroupInput"
v-model="newLocalGroupName"
size="sm"
class="group-item__input"
:placeholder="t('view.favorite.avatars.new_group')"
@keyup.enter="handleLocalGroupCreationConfirm"
@keyup.esc="cancelLocalGroupCreation"
@blur="cancelLocalGroupCreation" />
</div> </div>
</template> </div>
<template v-else> <div class="group-section">
<div class="favorites-empty">No Group Selected</div> <div class="group-section__header">
</template> <span>Local History</span>
<DropdownMenu
:open="activeGroupMenu === historyGroupMenuKey"
@update:open="handleGroupMenuVisible(historyGroupMenuKey, $event)">
<DropdownMenuTrigger asChild>
<Button class="rounded-full" size="icon-sm" variant="ghost" @click.stop
><Ellipsis
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-45">
<DropdownMenuItem variant="destructive" @click="handleHistoryClear">
<span>{{ t('view.favorite.clear_tooltip') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="group-section__list">
<div
:class="[
'group-item',
{ 'is-active': !hasSearchInput && isGroupActive('history', historyGroupKey) }
]"
@click="handleGroupClick('history', historyGroupKey)">
<div class="group-item__top">
<span class="group-item__name">Local History</span>
<span class="group-item__count">{{ avatarHistory.length }}/100</span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </ResizablePanel>
</ResizablePanel> <ResizableHandle @dragging="setAvatarSplitterDragging" />
</ResizablePanelGroup> <ResizablePanel :order="2">
<div class="favorites-content">
<div class="favorites-content__header">
<div class="favorites-content__title">
<span v-if="isSearchActive">{{ t('view.favorite.avatars.search') }}</span>
<template v-else-if="activeRemoteGroup">
<span>
{{ activeRemoteGroup.displayName }}
<small>{{ activeRemoteGroup.count }}/{{ activeRemoteGroup.capacity }}</small>
</span>
</template>
<template v-else-if="activeLocalGroupName">
<span>
{{ activeLocalGroupName }}
<small>{{ activeLocalGroupCount }}</small>
</span>
</template>
<template v-else-if="isHistorySelected">
<span>
Local History
<small>{{ avatarHistory.length }}/100</small>
</span>
</template>
<span v-else>No Group Selected</span>
</div>
<div class="favorites-content__edit">
<span>{{ t('view.favorite.edit_mode') }}</span>
<Switch v-model="avatarEditMode" :disabled="isSearchActive || !activeRemoteGroup" />
</div>
</div>
<div class="favorites-content__edit-actions">
<div
v-if="avatarEditMode && !isSearchActive && activeRemoteGroup"
class="favorites-content__actions">
<Button size="sm" variant="outline" @click="toggleSelectAllAvatars">
{{
isAllAvatarsSelected
? t('view.favorite.deselect_all')
: t('view.favorite.select_all')
}}
</Button>
<Button
size="sm"
variant="secondary"
:disabled="!hasAvatarSelection"
@click="clearSelectedAvatars">
{{ t('view.favorite.clear') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasAvatarSelection"
@click="copySelectedAvatars">
{{ t('view.favorite.copy') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasAvatarSelection"
@click="showAvatarBulkUnfavoriteSelectionConfirm">
{{ t('view.favorite.bulk_unfavorite') }}
</Button>
</div>
</div>
<div ref="avatarFavoritesContainerRef" class="favorites-content__list">
<template v-if="isSearchActive">
<div class="favorites-content__scroll favorites-content__scroll--native">
<div
v-if="avatarFavoriteSearchResults.length"
class="favorites-search-grid"
:style="avatarFavoritesGridStyle(avatarFavoriteSearchResults.length)">
<div
v-for="favorite in avatarFavoriteSearchResults"
:key="favorite.id"
class="favorites-search-card"
@click="showAvatarDialog(favorite.id)">
<div class="favorites-search-card__content">
<div
class="favorites-search-card__avatar"
:class="{ 'is-empty': !favorite.thumbnailImageUrl }">
<img
v-if="favorite.thumbnailImageUrl"
:src="favorite.thumbnailImageUrl"
loading="lazy" />
</div>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<span class="name">{{ favorite.name }}</span>
</div>
<span class="text-xs">{{ favorite.authorName }}</span>
</div>
</div>
</div>
</div>
<div v-else class="favorites-empty">
<DataTableEmpty type="nomatch" />
</div>
</div>
</template>
<template v-else-if="activeRemoteGroup">
<div class="favorites-content__scroll favorites-content__scroll--native">
<template v-if="currentRemoteFavorites.length">
<div
class="favorites-card-list"
:style="avatarFavoritesGridStyle(currentRemoteFavorites.length)">
<FavoritesAvatarItem
v-for="favorite in currentRemoteFavorites"
:key="favorite.id"
:favorite="favorite"
:group="activeRemoteGroup"
:selected="selectedFavoriteAvatars.includes(favorite.id)"
:edit-mode="avatarEditMode"
@toggle-select="toggleAvatarSelection(favorite.id, $event)"
@click="showAvatarDialog(favorite.id)" />
</div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div>
</div>
</template>
<template v-else-if="!remoteAvatarGroupsResolved">
<div class="favorites-content__scroll favorites-content__scroll--native">
<div
class="favorites-card-list"
:style="avatarFavoritesGridStyle(avatarGroupPlaceholders.length)">
<div
v-for="group in avatarGroupPlaceholders"
:key="group.key"
class="favorites-card-placeholder-box"></div>
</div>
</div>
</template>
<template v-else-if="activeLocalGroupName">
<ScrollArea class="favorites-content__scroll">
<template v-if="currentLocalFavorites.length">
<div
class="favorites-card-list"
:style="avatarFavoritesGridStyle(currentLocalFavorites.length)">
<FavoritesAvatarItem
v-for="favorite in currentLocalFavorites"
:key="favorite.id"
:favorite="favorite"
:group="activeLocalGroupName"
is-local-favorite
:edit-mode="avatarEditMode"
@click="showAvatarDialog(favorite.id)" />
</div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div>
</ScrollArea>
</template>
<template v-else-if="isHistorySelected">
<div class="favorites-content__scroll favorites-content__scroll--native">
<template v-if="avatarHistory.length">
<div
class="favorites-card-list"
:style="avatarFavoritesGridStyle(avatarHistory.length)">
<FavoritesAvatarLocalHistoryItem
v-for="favorite in avatarHistory"
:key="favorite.id"
:favorite="favorite"
@click="showAvatarDialog(favorite.id)" />
</div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div>
</div>
</template>
<template v-else>
<div class="favorites-empty">No Group Selected</div>
</template>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<AvatarExportDialog v-model:avatarExportDialogVisible="avatarExportDialogVisible" /> <AvatarExportDialog v-model:avatarExportDialogVisible="avatarExportDialogVisible" />
</div> </div>
</template> </template>

View File

@@ -1,295 +1,309 @@
<template> <template>
<div class="favorites-page x-container"> <div class="x-container">
<div class="favorites-toolbar"> <div class="favorites-page">
<div> <div class="favorites-toolbar">
<Select :model-value="sortFavorites" @update:modelValue="handleSortFavoritesChange"> <div>
<SelectTrigger size="sm" class="favorites-toolbar__select"> <Select :model-value="sortFavorites" @update:modelValue="handleSortFavoritesChange">
<span class="flex items-center gap-2"> <SelectTrigger size="sm" class="favorites-toolbar__select">
<ArrowUpDown class="h-4 w-4" /> <span class="flex items-center gap-2">
<SelectValue <ArrowUpDown class="h-4 w-4" />
:placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" /> <SelectValue
</span> :placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" />
</SelectTrigger> </span>
<SelectContent> </SelectTrigger>
<SelectGroup> <SelectContent>
<SelectItem <SelectGroup>
:value="false" <SelectItem
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')"> :value="false"
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }} :text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')">
</SelectItem> {{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
<SelectItem </SelectItem>
:value="true" <SelectItem
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')"> :value="true"
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }} :text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')">
</SelectItem> {{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
</SelectGroup> </SelectItem>
</SelectContent> </SelectGroup>
</Select> </SelectContent>
</div> </Select>
<div class="favorites-toolbar__right">
<InputGroupSearch
v-model="friendFavoriteSearch"
class="favorites-toolbar__search"
:placeholder="t('view.favorite.worlds.search')"
@input="searchFriendFavorites" />
<DropdownMenu v-model:open="friendToolbarMenuOpen">
<DropdownMenuTrigger as-child>
<Button class="rounded-full" size="icon-sm" variant="ghost"><Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="favorites-dropdown">
<li class="favorites-dropdown__control" @click.stop>
<div class="favorites-dropdown__control-header">
<span>Scale</span>
<span class="favorites-dropdown__control-value"> {{ friendCardScalePercent }}% </span>
</div>
<Slider
v-model="friendCardScaleValue"
class="favorites-dropdown__slider"
:min="friendCardScaleSlider.min"
:max="friendCardScaleSlider.max"
:step="friendCardScaleSlider.step" />
</li>
<li class="favorites-dropdown__control" @click.stop>
<div class="favorites-dropdown__control-header">
<span>Spacing</span>
<span class="favorites-dropdown__control-value"> {{ friendCardSpacingPercent }}% </span>
</div>
<Slider
v-model="friendCardSpacingValue"
class="favorites-dropdown__slider"
:min="friendCardSpacingSlider.min"
:max="friendCardSpacingSlider.max"
:step="friendCardSpacingSlider.step" />
</li>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleFriendImportClick">
{{ t('view.favorite.import') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleFriendExportClick">
{{ t('view.favorite.export') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ResizablePanelGroup
ref="friendSplitterGroupRef"
direction="horizontal"
class="favorites-splitter"
@layout="handleFriendSplitterLayout">
<ResizablePanel
ref="friendSplitterPanelRef"
:default-size="friendSplitterDefaultSize"
:min-size="friendSplitterMinSize"
:max-size="friendSplitterMaxSize"
:collapsed-size="0"
collapsible
:order="1">
<div class="favorites-groups-panel">
<div class="group-section">
<div class="group-section__header">
<span>{{ t('view.favorite.worlds.vrchat_favorites') }}</span>
<TooltipWrapper side="bottom" :content="t('view.favorite.refresh_favorites_tooltip')">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="isFavoriteLoading"
@click.stop="handleRefreshFavorites">
<Spinner v-if="isFavoriteLoading" />
<RefreshCw v-else />
</Button>
</TooltipWrapper>
</div>
<div class="group-section__list">
<template v-if="favoriteFriendGroups.length">
<div
v-for="group in favoriteFriendGroups"
:key="group.key"
:class="[
'group-item',
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
]"
@click="handleGroupClick('remote', group.key)">
<div class="group-item__top">
<span class="group-item__name">{{ group.displayName }}</span>
<span class="group-item__count">{{ group.count }}/{{ group.capacity }}</span>
</div>
<div class="group-item__bottom">
<Badge variant="outline">
{{ t(`view.favorite.visibility.${group.visibility}`) }}
</Badge>
<DropdownMenu
:open="activeGroupMenu === remoteGroupMenuKey(group.key)"
@update:open="
handleGroupMenuVisible(remoteGroupMenuKey(group.key), $event)
">
<DropdownMenuTrigger asChild>
<Button class="rounded-full" variant="ghost" size="icon-sm" @click.stop>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-55">
<DropdownMenuItem @click="handleRemoteRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.favorite.visibility_tooltip') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
side="right"
align="start"
class="w-[180px]">
<DropdownMenuCheckboxItem
v-for="visibility in friendGroupVisibilityOptions"
:key="visibility"
:model-value="group.visibility === visibility"
indicator-position="right"
@select="handleVisibilitySelection(group, visibility)">
<span>{{
t(`view.favorite.visibility.${visibility}`)
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
@click="handleRemoteClear(group)">
<span>{{ t('view.favorite.clear') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
<div v-else class="group-empty">
<DataTableEmpty type="nodata" />
</div>
</div>
</div>
</div> </div>
</ResizablePanel> <div class="favorites-toolbar__right">
<ResizableHandle @dragging="setFriendSplitterDragging" /> <InputGroupSearch
<ResizablePanel :order="2"> v-model="friendFavoriteSearch"
<div class="favorites-content"> class="favorites-toolbar__search"
<div class="favorites-content__header"> :placeholder="t('view.favorite.worlds.search')"
<div class="favorites-content__title"> @input="searchFriendFavorites" />
<span v-if="isSearchActive">{{ t('view.favorite.worlds.search') }}</span> <DropdownMenu v-model:open="friendToolbarMenuOpen">
<template v-else-if="activeRemoteGroup"> <DropdownMenuTrigger as-child>
<span> <Button class="rounded-full" size="icon-sm" variant="ghost"><Ellipsis /></Button>
{{ activeRemoteGroup.displayName }} </DropdownMenuTrigger>
<small>{{ activeRemoteGroup.count }}/{{ activeRemoteGroup.capacity }}</small> <DropdownMenuContent class="favorites-dropdown">
</span> <li class="favorites-dropdown__control" @click.stop>
</template> <div class="favorites-dropdown__control-header">
<span v-else>No Group Selected</span> <span>Scale</span>
</div> <span class="favorites-dropdown__control-value">
<div class="favorites-content__edit"> {{ friendCardScalePercent }}%
<span>{{ t('view.favorite.edit_mode') }}</span> </span>
<Switch v-model="friendEditMode" :disabled="isSearchActive || !activeRemoteGroup" /> </div>
</div> <Slider
</div> v-model="friendCardScaleValue"
<div class="favorites-content__edit-actions"> class="favorites-dropdown__slider"
<div v-if="friendEditMode && !isSearchActive" class="favorites-content__actions"> :min="friendCardScaleSlider.min"
<Button size="sm" variant="outline" @click="toggleSelectAllFriends"> :max="friendCardScaleSlider.max"
{{ :step="friendCardScaleSlider.step" />
isAllFriendsSelected </li>
? t('view.favorite.deselect_all') <li class="favorites-dropdown__control" @click.stop>
: t('view.favorite.select_all') <div class="favorites-dropdown__control-header">
}} <span>Spacing</span>
</Button> <span class="favorites-dropdown__control-value">
<Button {{ friendCardSpacingPercent }}%
size="sm" </span>
variant="secondary" </div>
:disabled="!hasFriendSelection" <Slider
@click="clearSelectedFriends"> v-model="friendCardSpacingValue"
{{ t('view.favorite.clear') }} class="favorites-dropdown__slider"
</Button> :min="friendCardSpacingSlider.min"
<Button :max="friendCardSpacingSlider.max"
size="sm" :step="friendCardSpacingSlider.step" />
variant="outline" </li>
:disabled="!hasFriendSelection" <DropdownMenuSeparator />
@click="copySelectedFriends"> <DropdownMenuItem @click="handleFriendImportClick">
{{ t('view.favorite.copy') }} {{ t('view.favorite.import') }}
</Button> </DropdownMenuItem>
<Button <DropdownMenuItem @click="handleFriendExportClick">
size="sm" {{ t('view.favorite.export') }}
variant="outline" </DropdownMenuItem>
:disabled="!hasFriendSelection" </DropdownMenuContent>
@click="showFriendBulkUnfavoriteSelectionConfirm"> </DropdownMenu>
{{ t('view.favorite.bulk_unfavorite') }} </div>
</Button> </div>
</div> <ResizablePanelGroup
</div> ref="friendSplitterGroupRef"
<div ref="friendFavoritesContainerRef" class="favorites-content__list"> direction="horizontal"
<template v-if="activeRemoteGroup && !isSearchActive"> class="favorites-splitter"
<div class="favorites-content__scroll favorites-content__scroll--native"> @layout="handleFriendSplitterLayout">
<template v-if="currentFriendFavorites.length"> <ResizablePanel
ref="friendSplitterPanelRef"
:default-size="friendSplitterDefaultSize"
:min-size="friendSplitterMinSize"
:max-size="friendSplitterMaxSize"
:collapsed-size="0"
collapsible
:order="1">
<div class="favorites-groups-panel">
<div class="group-section">
<div class="group-section__header">
<span>{{ t('view.favorite.worlds.vrchat_favorites') }}</span>
<TooltipWrapper side="bottom" :content="t('view.favorite.refresh_favorites_tooltip')">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="isFavoriteLoading"
@click.stop="handleRefreshFavorites">
<Spinner v-if="isFavoriteLoading" />
<RefreshCw v-else />
</Button>
</TooltipWrapper>
</div>
<div class="group-section__list">
<template v-if="favoriteFriendGroups.length">
<div <div
class="favorites-card-list" v-for="group in favoriteFriendGroups"
:style="friendFavoritesGridStyle(currentFriendFavorites.length)"> :key="group.key"
<FavoritesFriendItem :class="[
v-for="favorite in currentFriendFavorites" 'group-item',
:key="favorite.id" { 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
:favorite="favorite" ]"
:group="activeRemoteGroup" @click="handleGroupClick('remote', group.key)">
:selected="selectedFavoriteFriends.includes(favorite.id)" <div class="group-item__top">
:edit-mode="friendEditMode" <span class="group-item__name">{{ group.displayName }}</span>
@toggle-select="toggleFriendSelection(favorite.id, $event)" <span class="group-item__count"
@click="showUserDialog(favorite.id)" /> >{{ group.count }}/{{ group.capacity }}</span
>
</div>
<div class="group-item__bottom">
<Badge variant="outline">
{{ t(`view.favorite.visibility.${group.visibility}`) }}
</Badge>
<DropdownMenu
:open="activeGroupMenu === remoteGroupMenuKey(group.key)"
@update:open="
handleGroupMenuVisible(remoteGroupMenuKey(group.key), $event)
">
<DropdownMenuTrigger asChild>
<Button
class="rounded-full"
variant="ghost"
size="icon-sm"
@click.stop>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-55">
<DropdownMenuItem @click="handleRemoteRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.favorite.visibility_tooltip') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
side="right"
align="start"
class="w-[180px]">
<DropdownMenuCheckboxItem
v-for="visibility in friendGroupVisibilityOptions"
:key="visibility"
:model-value="group.visibility === visibility"
indicator-position="right"
@select="
handleVisibilitySelection(group, visibility)
">
<span>{{
t(`view.favorite.visibility.${visibility}`)
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
@click="handleRemoteClear(group)">
<span>{{ t('view.favorite.clear') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
</template> </template>
<div v-else class="favorites-empty"> <div v-else class="group-empty">
<DataTableEmpty type="nodata" /> <DataTableEmpty type="nodata" />
</div> </div>
</div> </div>
</template> </div>
<template v-else-if="!isSearchActive"> </div>
<div class="favorites-empty">No Group Selected</div> </ResizablePanel>
</template> <ResizableHandle @dragging="setFriendSplitterDragging" />
<template v-else> <ResizablePanel :order="2">
<div class="favorites-content__scroll favorites-content__scroll--native"> <div class="favorites-content">
<div <div class="favorites-content__header">
v-if="friendFavoriteSearchResults.length" <div class="favorites-content__title">
class="favorites-search-grid" <span v-if="isSearchActive">{{ t('view.favorite.worlds.search') }}</span>
:style="friendFavoritesGridStyle(friendFavoriteSearchResults.length)"> <template v-else-if="activeRemoteGroup">
<span>
{{ activeRemoteGroup.displayName }}
<small>{{ activeRemoteGroup.count }}/{{ activeRemoteGroup.capacity }}</small>
</span>
</template>
<span v-else>No Group Selected</span>
</div>
<div class="favorites-content__edit">
<span>{{ t('view.favorite.edit_mode') }}</span>
<Switch v-model="friendEditMode" :disabled="isSearchActive || !activeRemoteGroup" />
</div>
</div>
<div class="favorites-content__edit-actions">
<div v-if="friendEditMode && !isSearchActive" class="favorites-content__actions">
<Button size="sm" variant="outline" @click="toggleSelectAllFriends">
{{
isAllFriendsSelected
? t('view.favorite.deselect_all')
: t('view.favorite.select_all')
}}
</Button>
<Button
size="sm"
variant="secondary"
:disabled="!hasFriendSelection"
@click="clearSelectedFriends">
{{ t('view.favorite.clear') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasFriendSelection"
@click="copySelectedFriends">
{{ t('view.favorite.copy') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasFriendSelection"
@click="showFriendBulkUnfavoriteSelectionConfirm">
{{ t('view.favorite.bulk_unfavorite') }}
</Button>
</div>
</div>
<div ref="friendFavoritesContainerRef" class="favorites-content__list">
<template v-if="activeRemoteGroup && !isSearchActive">
<div class="favorites-content__scroll favorites-content__scroll--native">
<template v-if="currentFriendFavorites.length">
<div
class="favorites-card-list"
:style="friendFavoritesGridStyle(currentFriendFavorites.length)">
<FavoritesFriendItem
v-for="favorite in currentFriendFavorites"
:key="favorite.id"
:favorite="favorite"
:group="activeRemoteGroup"
:selected="selectedFavoriteFriends.includes(favorite.id)"
:edit-mode="friendEditMode"
@toggle-select="toggleFriendSelection(favorite.id, $event)"
@click="showUserDialog(favorite.id)" />
</div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div>
</div>
</template>
<template v-else-if="!isSearchActive">
<div class="favorites-empty">No Group Selected</div>
</template>
<template v-else>
<div class="favorites-content__scroll favorites-content__scroll--native">
<div <div
v-for="favorite in friendFavoriteSearchResults" v-if="friendFavoriteSearchResults.length"
:key="favorite.id" class="favorites-search-grid"
class="favorites-search-card" :style="friendFavoritesGridStyle(friendFavoriteSearchResults.length)">
@click="showUserDialog(favorite.id)"> <div
<div class="favorites-search-card__content"> v-for="favorite in friendFavoriteSearchResults"
<div class="favorites-search-card__avatar"> :key="favorite.id"
<img :src="userImage(favorite, true)" loading="lazy" /> class="favorites-search-card"
</div> @click="showUserDialog(favorite.id)">
<div class="favorites-search-card__detail"> <div class="favorites-search-card__content">
<div class="favorites-search-card__title"> <div class="favorites-search-card__avatar">
<span class="name">{{ favorite.displayName }}</span> <img :src="userImage(favorite, true)" loading="lazy" />
</div> </div>
<div <div class="favorites-search-card__detail">
v-if="favorite.location && favorite.location !== 'offline'" <div class="favorites-search-card__title">
class="favorites-search-card__location"> <span class="name">{{ favorite.displayName }}</span>
<Location </div>
:location="favorite.location" <div
:traveling="favorite.travelingToLocation" v-if="favorite.location && favorite.location !== 'offline'"
:link="false" /> class="favorites-search-card__location">
<Location
:location="favorite.location"
:traveling="favorite.travelingToLocation"
:link="false" />
</div>
<span v-else class="text-xs">{{ favorite.statusDescription }}</span>
</div> </div>
<span v-else class="text-xs">{{ favorite.statusDescription }}</span>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="favorites-empty">
<DataTableEmpty type="nomatch" />
</div>
</div> </div>
<div v-else class="favorites-empty"> </template>
<DataTableEmpty type="nomatch" /> </div>
</div>
</div>
</template>
</div> </div>
</div> </ResizablePanel>
</ResizablePanel> </ResizablePanelGroup>
</ResizablePanelGroup> </div>
<FriendExportDialog v-model:friendExportDialogVisible="friendExportDialogVisible" /> <FriendExportDialog v-model:friendExportDialogVisible="friendExportDialogVisible" />
</div> </div>
</template> </template>

View File

@@ -1,435 +1,453 @@
<template> <template>
<div class="favorites-page x-container"> <div class="x-container">
<div class="favorites-toolbar"> <div class="favorites-page">
<div> <div class="favorites-toolbar">
<Select :model-value="sortFavorites" @update:modelValue="handleSortFavoritesChange"> <div>
<SelectTrigger size="sm" class="favorites-toolbar__select"> <Select :model-value="sortFavorites" @update:modelValue="handleSortFavoritesChange">
<span class="flex items-center gap-2"> <SelectTrigger size="sm" class="favorites-toolbar__select">
<ArrowUpDown class="h-4 w-4" /> <span class="flex items-center gap-2">
<SelectValue <ArrowUpDown class="h-4 w-4" />
:placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" /> <SelectValue
</span> :placeholder="t('view.settings.appearance.appearance.sort_favorite_by_name')" />
</SelectTrigger> </span>
<SelectContent> </SelectTrigger>
<SelectGroup> <SelectContent>
<SelectItem <SelectGroup>
:value="false" <SelectItem
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')"> :value="false"
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }} :text-value="t('view.settings.appearance.appearance.sort_favorite_by_name')">
</SelectItem> {{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
<SelectItem </SelectItem>
:value="true" <SelectItem
:text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')"> :value="true"
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }} :text-value="t('view.settings.appearance.appearance.sort_favorite_by_date')">
</SelectItem> {{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
</SelectGroup> </SelectItem>
</SelectContent> </SelectGroup>
</Select> </SelectContent>
</Select>
</div>
<div class="favorites-toolbar__right">
<InputGroupSearch
v-model="worldFavoriteSearch"
class="favorites-toolbar__search"
:placeholder="t('view.favorite.worlds.search')"
@input="searchWorldFavorites" />
<DropdownMenu v-model:open="worldToolbarMenuOpen">
<DropdownMenuTrigger as-child>
<Button class="rounded-full" size="icon" variant="ghost"><Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="favorites-dropdown">
<li class="favorites-dropdown__control" @click.stop>
<div class="favorites-dropdown__control-header">
<span>Scale</span>
<span class="favorites-dropdown__control-value">
{{ worldCardScalePercent }}%
</span>
</div>
<Slider
v-model="worldCardScaleValue"
class="favorites-dropdown__slider"
:min="worldCardScaleSlider.min"
:max="worldCardScaleSlider.max"
:step="worldCardScaleSlider.step" />
</li>
<li class="favorites-dropdown__control" @click.stop>
<div class="favorites-dropdown__control-header">
<span>Spacing</span>
<span class="favorites-dropdown__control-value">
{{ worldCardSpacingPercent }}%
</span>
</div>
<Slider
v-model="worldCardSpacingValue"
class="favorites-dropdown__slider"
:min="worldCardSpacingSlider.min"
:max="worldCardSpacingSlider.max"
:step="worldCardSpacingSlider.step" />
</li>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleWorldImportClick">
{{ t('view.favorite.import') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleWorldExportClick">
{{ t('view.favorite.export') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
<div class="favorites-toolbar__right"> <ResizablePanelGroup
<InputGroupSearch ref="worldSplitterGroupRef"
v-model="worldFavoriteSearch" direction="horizontal"
class="favorites-toolbar__search" class="favorites-splitter"
:placeholder="t('view.favorite.worlds.search')" @layout="handleWorldSplitterLayout">
@input="searchWorldFavorites" /> <ResizablePanel
<DropdownMenu v-model:open="worldToolbarMenuOpen"> ref="worldSplitterPanelRef"
<DropdownMenuTrigger as-child> :default-size="worldSplitterDefaultSize"
<Button class="rounded-full" size="icon" variant="ghost"><Ellipsis /></Button> :min-size="worldSplitterMinSize"
</DropdownMenuTrigger> :max-size="worldSplitterMaxSize"
<DropdownMenuContent class="favorites-dropdown"> :collapsed-size="0"
<li class="favorites-dropdown__control" @click.stop> collapsible
<div class="favorites-dropdown__control-header"> :order="1">
<span>Scale</span> <div class="favorites-groups-panel">
<span class="favorites-dropdown__control-value"> {{ worldCardScalePercent }}% </span> <div class="group-section">
<div class="group-section__header">
<span>{{ t('view.favorite.worlds.vrchat_favorites') }}</span>
<TooltipWrapper side="bottom" :content="t('view.favorite.refresh_favorites_tooltip')">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="isFavoriteLoading"
@click.stop="handleRefreshFavorites">
<Spinner v-if="isFavoriteLoading" />
<RefreshCw v-else />
</Button>
</TooltipWrapper>
</div> </div>
<Slider <div class="group-section__list">
v-model="worldCardScaleValue" <template v-if="favoriteWorldGroups.length">
class="favorites-dropdown__slider" <div
:min="worldCardScaleSlider.min" v-for="group in favoriteWorldGroups"
:max="worldCardScaleSlider.max" :key="group.key"
:step="worldCardScaleSlider.step" /> :class="[
</li> 'group-item',
<li class="favorites-dropdown__control" @click.stop> { 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
<div class="favorites-dropdown__control-header"> ]"
<span>Spacing</span> @click="handleGroupClick('remote', group.key)">
<span class="favorites-dropdown__control-value"> {{ worldCardSpacingPercent }}% </span> <div class="group-item__top">
<span class="group-item__name">{{ group.displayName }}</span>
<span class="group-item__count"
>{{ group.count }}/{{ group.capacity }}</span
>
</div>
<div class="group-item__bottom">
<Badge variant="outline">
{{ t(`view.favorite.visibility.${group.visibility}`) }}
</Badge>
<DropdownMenu
:open="activeGroupMenu === remoteGroupMenuKey(group.key)"
@update:open="
handleGroupMenuVisible(remoteGroupMenuKey(group.key), $event)
">
<DropdownMenuTrigger asChild>
<Button
class="rounded-full"
variant="ghost"
size="icon-sm"
@click.stop>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-50">
<DropdownMenuItem @click="handleRemoteRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.favorite.visibility_tooltip') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
side="right"
align="start"
class="w-[200px]">
<DropdownMenuCheckboxItem
v-for="visibility in worldGroupVisibilityOptions"
:key="visibility"
:model-value="group.visibility === visibility"
indicator-position="right"
@select="
handleVisibilitySelection(group, visibility)
">
<span>{{
t(`view.favorite.visibility.${visibility}`)
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
@click="handleRemoteClear(group)">
<span>{{ t('view.favorite.clear') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
<template v-else>
<div
v-for="group in worldGroupPlaceholders"
:key="group.key"
:class="[
'group-item',
'group-item--placeholder',
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) }
]">
<div class="group-item__top">
<span class="group-item__name">{{ group.displayName }}</span>
<span class="group-item__count">--/--</span>
</div>
<div class="group-item__bottom">
<div class="group-item__placeholder-tag"></div>
</div>
</div>
</template>
</div> </div>
<Slider </div>
v-model="worldCardSpacingValue" <div class="group-section">
class="favorites-dropdown__slider" <div class="group-section__header">
:min="worldCardSpacingSlider.min" <span>{{ t('view.favorite.worlds.local_favorites') }}</span>
:max="worldCardSpacingSlider.max"
:step="worldCardSpacingSlider.step" />
</li>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleWorldImportClick">
{{ t('view.favorite.import') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleWorldExportClick">
{{ t('view.favorite.export') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ResizablePanelGroup
ref="worldSplitterGroupRef"
direction="horizontal"
class="favorites-splitter"
@layout="handleWorldSplitterLayout">
<ResizablePanel
ref="worldSplitterPanelRef"
:default-size="worldSplitterDefaultSize"
:min-size="worldSplitterMinSize"
:max-size="worldSplitterMaxSize"
:collapsed-size="0"
collapsible
:order="1">
<div class="favorites-groups-panel">
<div class="group-section">
<div class="group-section__header">
<span>{{ t('view.favorite.worlds.vrchat_favorites') }}</span>
<TooltipWrapper side="bottom" :content="t('view.favorite.refresh_favorites_tooltip')">
<Button <Button
class="rounded-full" class="rounded-full"
variant="outline"
size="icon-sm" size="icon-sm"
:disabled="isFavoriteLoading" variant="outline"
@click.stop="handleRefreshFavorites"> v-if="!refreshingLocalFavorites"
<Spinner v-if="isFavoriteLoading" /> @click.stop="refreshLocalWorldFavorites"
<RefreshCw v-else /> ><RefreshCcw
/></Button>
<Button size="icon-sm" variant="ghost" v-else @click.stop="cancelLocalWorldRefresh">
<RefreshCcw />
{{ t('view.favorite.worlds.cancel_refresh') }}
</Button> </Button>
</TooltipWrapper> </div>
</div> <div class="group-section__list">
<div class="group-section__list"> <template v-if="localWorldFavoriteGroups.length">
<template v-if="favoriteWorldGroups.length"> <div
<div v-for="group in localWorldFavoriteGroups"
v-for="group in favoriteWorldGroups" :key="group"
:key="group.key" :class="[
:class="[ 'group-item',
'group-item', { 'is-active': !hasSearchInput && isGroupActive('local', group) }
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) } ]"
]" @click="handleGroupClick('local', group)">
@click="handleGroupClick('remote', group.key)"> <div class="group-item__top">
<div class="group-item__top"> <span class="group-item__name">{{ group }}</span>
<span class="group-item__name">{{ group.displayName }}</span> <div class="group-item__right">
<span class="group-item__count">{{ group.count }}/{{ group.capacity }}</span> <span class="group-item__count">{{
localWorldFavGroupLength(group)
}}</span>
<div class="group-item__bottom">
<DropdownMenu
:open="activeGroupMenu === localGroupMenuKey(group)"
@update:open="
handleGroupMenuVisible(localGroupMenuKey(group), $event)
">
<DropdownMenuTrigger asChild>
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click.stop
><Ellipsis
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-50">
<DropdownMenuItem @click="handleLocalRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="handleLocalDelete(group)">
<span>{{ t('view.favorite.delete_tooltip') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div> </div>
<div class="group-item__bottom"> </template>
<Badge variant="outline"> <div v-else class="group-empty">
{{ t(`view.favorite.visibility.${group.visibility}`) }} <DataTableEmpty type="nodata" />
</Badge> </div>
<DropdownMenu <div
:open="activeGroupMenu === remoteGroupMenuKey(group.key)" v-if="!isCreatingLocalGroup"
@update:open=" class="group-item group-item--new"
handleGroupMenuVisible(remoteGroupMenuKey(group.key), $event) @click="startLocalGroupCreation">
"> <Plus />
<DropdownMenuTrigger asChild> <span>{{ t('view.favorite.worlds.new_group') }}</span>
<Button class="rounded-full" variant="ghost" size="icon-sm" @click.stop> </div>
<MoreHorizontal /> <InputGroupField
</Button> v-else
</DropdownMenuTrigger> ref="newLocalGroupInput"
<DropdownMenuContent side="right" class="w-50"> v-model="newLocalGroupName"
<DropdownMenuItem @click="handleRemoteRename(group)"> size="sm"
<span>{{ t('view.favorite.rename_tooltip') }}</span> class="group-item__input"
</DropdownMenuItem> :placeholder="t('view.favorite.worlds.new_group')"
<DropdownMenuSub> @keyup.enter="handleLocalGroupCreationConfirm"
<DropdownMenuSubTrigger> @keyup.esc="cancelLocalGroupCreation"
<span>{{ t('view.favorite.visibility_tooltip') }}</span> @blur="cancelLocalGroupCreation" />
</DropdownMenuSubTrigger> </div>
<DropdownMenuPortal> </div>
<DropdownMenuSubContent </div>
side="right" </ResizablePanel>
align="start" <ResizableHandle @dragging="setWorldSplitterDragging" />
class="w-[200px]"> <ResizablePanel :order="2">
<DropdownMenuCheckboxItem <div class="favorites-content">
v-for="visibility in worldGroupVisibilityOptions" <div class="favorites-content__header">
:key="visibility" <div class="favorites-content__title">
:model-value="group.visibility === visibility" <span v-if="isSearchActive">{{ t('view.favorite.worlds.search') }}</span>
indicator-position="right" <template v-else-if="activeRemoteGroup">
@select="handleVisibilitySelection(group, visibility)"> <span
<span>{{ >{{ activeRemoteGroup.displayName }} &nbsp;<small
t(`view.favorite.visibility.${visibility}`) >{{ activeRemoteGroup.count }}/{{ activeRemoteGroup.capacity }}</small
}}</span> ></span
</DropdownMenuCheckboxItem> >
</DropdownMenuSubContent> </template>
</DropdownMenuPortal> <span v-else-if="activeLocalGroupName">
</DropdownMenuSub> {{ activeLocalGroupName }}
<DropdownMenuItem <small>{{ activeLocalGroupCount }}</small>
variant="destructive" </span>
@click="handleRemoteClear(group)"> <span v-else>No Group Selected</span>
<span>{{ t('view.favorite.clear') }}</span> </div>
</DropdownMenuItem> <div class="favorites-content__edit">
</DropdownMenuContent> <span>{{ t('view.favorite.edit_mode') }}</span>
</DropdownMenu> <Switch v-model="worldEditMode" :disabled="isSearchActive" />
</div>
</div>
<div class="favorites-content__edit-actions">
<div v-if="worldEditMode && !isSearchActive" class="favorites-content__actions">
<Button size="sm" variant="outline" @click="toggleSelectAllWorlds">
{{
isAllWorldsSelected
? t('view.favorite.deselect_all')
: t('view.favorite.select_all')
}}
</Button>
<Button
size="sm"
variant="secondary"
:disabled="!hasWorldSelection"
@click="clearSelectedWorlds">
{{ t('view.favorite.clear') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasWorldSelection"
@click="copySelectedWorlds">
{{ t('view.favorite.copy') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasWorldSelection"
@click="showWorldBulkUnfavoriteSelectionConfirm">
{{ t('view.favorite.bulk_unfavorite') }}
</Button>
</div>
</div>
<div ref="worldFavoritesContainerRef" class="favorites-content__list">
<template v-if="isSearchActive">
<div class="favorites-content__scroll favorites-content__scroll--native">
<div
v-if="worldFavoriteSearchResults.length"
class="favorites-search-grid"
:style="worldFavoritesGridStyle(worldFavoriteSearchResults.length)">
<div
v-for="favorite in worldFavoriteSearchResults"
:key="favorite.id"
class="favorites-search-card"
@click="showWorldDialog(favorite.id)">
<div class="favorites-search-card__content">
<div
class="favorites-search-card__avatar"
:class="{ 'is-empty': !favorite.thumbnailImageUrl }">
<img
v-if="favorite.thumbnailImageUrl"
:src="favorite.thumbnailImageUrl"
loading="lazy" />
</div>
<div class="favorites-search-card__detail">
<span class="name">{{ favorite.name || favorite.id }}</span>
<span class="text-xs">
{{ favorite.authorName }}
<template v-if="favorite.occupants">
({{ favorite.occupants }})
</template>
</span>
</div>
</div>
</div>
</div>
<div v-else class="favorites-empty">
<DataTableEmpty type="nomatch" />
</div> </div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div <div
v-for="group in worldGroupPlaceholders" v-if="activeRemoteGroup && isRemoteGroupSelected"
:key="group.key" class="favorites-content__scroll favorites-content__scroll--native">
:class="[ <template v-if="currentRemoteFavorites.length">
'group-item', <div
'group-item--placeholder', class="favorites-card-list"
{ 'is-active': !hasSearchInput && isGroupActive('remote', group.key) } :style="worldFavoritesGridStyle(currentRemoteFavorites.length)">
]"> <FavoritesWorldItem
<div class="group-item__top"> v-for="favorite in currentRemoteFavorites"
<span class="group-item__name">{{ group.displayName }}</span> :key="favorite.id"
<span class="group-item__count">--/--</span> :group="activeRemoteGroup"
</div> :favorite="favorite"
<div class="group-item__bottom"> :edit-mode="worldEditMode"
<div class="group-item__placeholder-tag"></div> :selected="selectedFavoriteWorlds.includes(favorite.id)"
</div> @toggle-select="toggleWorldSelection(favorite.id, $event)"
</div> @click="showWorldDialog(favorite.id)" />
</template>
</div>
</div>
<div class="group-section">
<div class="group-section__header">
<span>{{ t('view.favorite.worlds.local_favorites') }}</span>
<Button
class="rounded-full"
size="icon-sm"
variant="outline"
v-if="!refreshingLocalFavorites"
@click.stop="refreshLocalWorldFavorites"
><RefreshCcw
/></Button>
<Button size="icon-sm" variant="ghost" v-else @click.stop="cancelLocalWorldRefresh">
<RefreshCcw />
{{ t('view.favorite.worlds.cancel_refresh') }}
</Button>
</div>
<div class="group-section__list">
<template v-if="localWorldFavoriteGroups.length">
<div
v-for="group in localWorldFavoriteGroups"
:key="group"
:class="[
'group-item',
{ 'is-active': !hasSearchInput && isGroupActive('local', group) }
]"
@click="handleGroupClick('local', group)">
<div class="group-item__top">
<span class="group-item__name">{{ group }}</span>
<div class="group-item__right">
<span class="group-item__count">{{ localWorldFavGroupLength(group) }}</span>
<div class="group-item__bottom">
<DropdownMenu
:open="activeGroupMenu === localGroupMenuKey(group)"
@update:open="
handleGroupMenuVisible(localGroupMenuKey(group), $event)
">
<DropdownMenuTrigger asChild>
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click.stop
><Ellipsis
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" class="w-50">
<DropdownMenuItem @click="handleLocalRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="handleLocalDelete(group)">
<span>{{ t('view.favorite.delete_tooltip') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div> </div>
</div> </div>
</template>
<div v-else class="group-empty">
<DataTableEmpty type="nodata" />
</div>
<div
v-if="!isCreatingLocalGroup"
class="group-item group-item--new"
@click="startLocalGroupCreation">
<Plus />
<span>{{ t('view.favorite.worlds.new_group') }}</span>
</div>
<InputGroupField
v-else
ref="newLocalGroupInput"
v-model="newLocalGroupName"
size="sm"
class="group-item__input"
:placeholder="t('view.favorite.worlds.new_group')"
@keyup.enter="handleLocalGroupCreationConfirm"
@keyup.esc="cancelLocalGroupCreation"
@blur="cancelLocalGroupCreation" />
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle @dragging="setWorldSplitterDragging" />
<ResizablePanel :order="2">
<div class="favorites-content">
<div class="favorites-content__header">
<div class="favorites-content__title">
<span v-if="isSearchActive">{{ t('view.favorite.worlds.search') }}</span>
<template v-else-if="activeRemoteGroup">
<span
>{{ activeRemoteGroup.displayName }} &nbsp;<small
>{{ activeRemoteGroup.count }}/{{ activeRemoteGroup.capacity }}</small
></span
>
</template>
<span v-else-if="activeLocalGroupName">
{{ activeLocalGroupName }}
<small>{{ activeLocalGroupCount }}</small>
</span>
<span v-else>No Group Selected</span>
</div>
<div class="favorites-content__edit">
<span>{{ t('view.favorite.edit_mode') }}</span>
<Switch v-model="worldEditMode" :disabled="isSearchActive" />
</div>
</div>
<div class="favorites-content__edit-actions">
<div v-if="worldEditMode && !isSearchActive" class="favorites-content__actions">
<Button size="sm" variant="outline" @click="toggleSelectAllWorlds">
{{
isAllWorldsSelected
? t('view.favorite.deselect_all')
: t('view.favorite.select_all')
}}
</Button>
<Button
size="sm"
variant="secondary"
:disabled="!hasWorldSelection"
@click="clearSelectedWorlds">
{{ t('view.favorite.clear') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasWorldSelection"
@click="copySelectedWorlds">
{{ t('view.favorite.copy') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!hasWorldSelection"
@click="showWorldBulkUnfavoriteSelectionConfirm">
{{ t('view.favorite.bulk_unfavorite') }}
</Button>
</div>
</div>
<div ref="worldFavoritesContainerRef" class="favorites-content__list">
<template v-if="isSearchActive">
<div class="favorites-content__scroll favorites-content__scroll--native">
<div <div
v-if="worldFavoriteSearchResults.length" v-else-if="activeLocalGroupName && isLocalGroupSelected"
class="favorites-search-grid" ref="localFavoritesViewportRef"
:style="worldFavoritesGridStyle(worldFavoriteSearchResults.length)"> class="favorites-content__scroll favorites-content__scroll--native favorites-content__scroll--local focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
<div data-reka-scroll-area-viewport=""
v-for="favorite in worldFavoriteSearchResults" data-slot="scroll-area-viewport"
:key="favorite.id" tabindex="0"
class="favorites-search-card" style="overflow: hidden scroll">
@click="showWorldDialog(favorite.id)"> <template v-if="currentLocalFavorites.length">
<div class="favorites-search-card__content"> <div class="favorites-card-virtual" :style="localVirtualContainerStyle">
<div <template
class="favorites-search-card__avatar" v-for="item in localVirtualItems"
:class="{ 'is-empty': !favorite.thumbnailImageUrl }"> :key="String(item.virtualItem.key)">
<img <div
v-if="favorite.thumbnailImageUrl" v-if="item.row"
:src="favorite.thumbnailImageUrl" class="favorites-card-virtual-row"
loading="lazy" /> :data-index="item.virtualItem.index"
</div> :ref="localVirtualizer.measureElement"
<div class="favorites-search-card__detail"> :style="{ transform: `translateY(${item.virtualItem.start}px)` }">
<span class="name">{{ favorite.name || favorite.id }}</span> <div class="favorites-card-virtual-row-grid">
<span class="text-xs"> <FavoritesWorldLocalItem
{{ favorite.authorName }} v-for="favorite in getLocalRowItems(item.row)"
<template v-if="favorite.occupants"> :key="favorite.key"
({{ favorite.occupants }}) :group="activeLocalGroupName"
</template> :favorite="favorite.favorite"
</span> :edit-mode="worldEditMode"
</div> @remove-local-world-favorite="removeLocalWorldFavorite"
</div> @click="showWorldDialog(favorite.favorite.id)" />
</div> </div>
</div>
<div v-else class="favorites-empty">
<DataTableEmpty type="nomatch" />
</div>
</div>
</template>
<template v-else>
<div
v-if="activeRemoteGroup && isRemoteGroupSelected"
class="favorites-content__scroll favorites-content__scroll--native">
<template v-if="currentRemoteFavorites.length">
<div
class="favorites-card-list"
:style="worldFavoritesGridStyle(currentRemoteFavorites.length)">
<FavoritesWorldItem
v-for="favorite in currentRemoteFavorites"
:key="favorite.id"
:group="activeRemoteGroup"
:favorite="favorite"
:edit-mode="worldEditMode"
:selected="selectedFavoriteWorlds.includes(favorite.id)"
@toggle-select="toggleWorldSelection(favorite.id, $event)"
@click="showWorldDialog(favorite.id)" />
</div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div>
</div>
<div
v-else-if="activeLocalGroupName && isLocalGroupSelected"
ref="localFavoritesViewportRef"
class="favorites-content__scroll favorites-content__scroll--native favorites-content__scroll--local focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
data-reka-scroll-area-viewport=""
data-slot="scroll-area-viewport"
tabindex="0"
style="overflow: hidden scroll">
<template v-if="currentLocalFavorites.length">
<div class="favorites-card-virtual" :style="localVirtualContainerStyle">
<template v-for="item in localVirtualItems" :key="String(item.virtualItem.key)">
<div
v-if="item.row"
class="favorites-card-virtual-row"
:data-index="item.virtualItem.index"
:ref="localVirtualizer.measureElement"
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
<div class="favorites-card-virtual-row-grid">
<FavoritesWorldLocalItem
v-for="favorite in getLocalRowItems(item.row)"
:key="favorite.key"
:group="activeLocalGroupName"
:favorite="favorite.favorite"
:edit-mode="worldEditMode"
@remove-local-world-favorite="removeLocalWorldFavorite"
@click="showWorldDialog(favorite.favorite.id)" />
</div> </div>
</div> </template>
</template> </div>
</template>
<div v-else class="favorites-empty">
<DataTableEmpty type="nodata" />
</div> </div>
</template> </div>
<div v-else class="favorites-empty"> <div v-else class="favorites-empty">
<DataTableEmpty type="nodata" /> <DataTableEmpty type="nodata" />
</div> </div>
</div> </template>
<div v-else class="favorites-empty"> </div>
<DataTableEmpty type="nodata" />
</div>
</template>
</div> </div>
</div> </ResizablePanel>
</ResizablePanel> </ResizablePanelGroup>
</ResizablePanelGroup> </div>
<WorldExportDialog v-model:worldExportDialogVisible="worldExportDialogVisible" /> <WorldExportDialog v-model:worldExportDialogVisible="worldExportDialogVisible" />
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="x-container" ref="playerListRef"> <div class="x-container" ref="playerListRef">
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col overflow-auto">
<div <div
v-if="currentInstanceWorld.ref.id" v-if="currentInstanceWorld.ref.id"
ref="playerListHeaderRef" ref="playerListHeaderRef"