refactor: favorites tab

This commit is contained in:
pa
2025-11-09 21:59:24 +09:00
committed by Natsumi
parent 9069d8cefe
commit bbd5fa2b21
17 changed files with 4987 additions and 1841 deletions

View File

@@ -1,221 +0,0 @@
<template>
<div class="x-container">
<div class="header">
<div v-if="editFavoritesMode" style="display: inline-block; margin-right: 10px">
<el-button size="small" @click="clearBulkFavoriteSelection">{{ t('view.favorite.clear') }}</el-button>
<el-button size="small" @click="handleBulkCopyFavoriteSelection">{{
t('view.favorite.copy')
}}</el-button>
<el-button size="small" @click="showBulkUnfavoriteSelectionConfirm">{{
t('view.favorite.bulk_unfavorite')
}}</el-button>
</div>
<div style="display: flex; align-items: center; margin-right: 10px">
<span class="name">{{ t('view.favorite.edit_mode') }}</span>
<el-switch v-model="editFavoritesMode" style="margin-left: 5px"></el-switch>
</div>
<el-tooltip placement="bottom" :content="t('view.favorite.refresh_favorites_tooltip')" :teleported="false">
<el-button
type="default"
:loading="isFavoriteLoading"
size="small"
:icon="Refresh"
circle
@click="
refreshFavorites();
getLocalWorldFavorites();
"></el-button>
</el-tooltip>
</div>
<el-tabs v-model="currentFavoriteTab" v-loading="isFavoriteLoading" type="card" style="height: 100%">
<el-tab-pane name="friend" :label="t('view.favorite.friends.header')">
<FavoritesFriendTab @change-favorite-group-name="changeFavoriteGroupName" />
</el-tab-pane>
<el-tab-pane name="world" :label="t('view.favorite.worlds.header')" lazy>
<FavoritesWorldTab @change-favorite-group-name="changeFavoriteGroupName" />
</el-tab-pane>
<el-tab-pane name="avatar" :label="t('view.favorite.avatars.header')" lazy>
<FavoritesAvatarTab @change-favorite-group-name="changeFavoriteGroupName" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ElMessage, ElMessageBox } from 'element-plus';
import { Refresh } from '@element-plus/icons-vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { favoriteRequest } from '../../api';
import { useFavoriteStore } from '../../stores';
import FavoritesAvatarTab from './components/FavoritesAvatarTab.vue';
import FavoritesFriendTab from './components/FavoritesFriendTab.vue';
import FavoritesWorldTab from './components/FavoritesWorldTab.vue';
const { t } = useI18n();
const {
favoriteFriends,
favoriteWorlds,
favoriteAvatars,
isFavoriteLoading,
avatarImportDialogInput,
worldImportDialogInput,
friendImportDialogInput,
currentFavoriteTab,
editFavoritesMode
} = storeToRefs(useFavoriteStore());
const {
refreshFavorites,
refreshFavoriteGroups,
clearBulkFavoriteSelection,
getLocalWorldFavorites,
handleFavoriteGroup,
showFriendImportDialog,
showWorldImportDialog,
showAvatarImportDialog
} = useFavoriteStore();
function showBulkUnfavoriteSelectionConfirm() {
const elementsTicked = [];
// check favorites type
for (const ctx of favoriteFriends.value) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
for (const ctx of favoriteWorlds.value) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
for (const ctx of favoriteAvatars.value) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
if (elementsTicked.length === 0) {
return;
}
ElMessageBox.confirm(
`Are you sure you want to unfavorite ${elementsTicked.length} favorites?
This action cannot be undone.`,
`Delete ${elementsTicked.length} favorites?`,
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info'
}
)
.then((action) => {
if (action === 'confirm') {
bulkUnfavoriteSelection(elementsTicked);
}
})
.catch(() => {});
}
function bulkUnfavoriteSelection(elementsTicked) {
for (const id of elementsTicked) {
favoriteRequest.deleteFavorite({
objectId: id
});
}
editFavoritesMode.value = false;
}
function changeFavoriteGroupName(ctx) {
ElMessageBox.prompt(
t('prompt.change_favorite_group_name.description'),
t('prompt.change_favorite_group_name.header'),
{
distinguishCancelAndClose: true,
cancelButtonText: t('prompt.change_favorite_group_name.cancel'),
confirmButtonText: t('prompt.change_favorite_group_name.change'),
inputPlaceholder: t('prompt.change_favorite_group_name.input_placeholder'),
inputValue: ctx.displayName,
inputPattern: /\S+/,
inputErrorMessage: t('prompt.change_favorite_group_name.input_error')
}
)
.then(({ value }) => {
favoriteRequest
.saveFavoriteGroup({
type: ctx.type,
group: ctx.name,
displayName: value
})
.then((args) => {
handleFavoriteGroup({
json: args.json,
params: {
favoriteGroupId: args.json.id
}
});
ElMessage({
message: t('prompt.change_favorite_group_name.message.success'),
type: 'success'
});
// load new group name
refreshFavoriteGroups();
});
})
.catch(() => {});
}
function handleBulkCopyFavoriteSelection() {
let idList = '';
switch (currentFavoriteTab.value) {
case 'friend':
for (const ctx of favoriteFriends.value) {
if (ctx.$selected) {
idList += `${ctx.id}\n`;
}
}
friendImportDialogInput.value = idList;
showFriendImportDialog();
break;
case 'world':
for (const ctx of favoriteWorlds.value) {
if (ctx.$selected) {
idList += `${ctx.id}\n`;
}
}
worldImportDialogInput.value = idList;
showWorldImportDialog();
break;
case 'avatar':
for (const ctx of favoriteAvatars.value) {
if (ctx.$selected) {
idList += `${ctx.id}\n`;
}
}
avatarImportDialogInput.value = idList;
showAvatarImportDialog();
break;
default:
break;
}
console.log('Favorite selection\n', idList);
}
</script>
<style scoped>
.header {
font-size: 13px;
position: absolute;
display: flex;
align-items: center;
right: 0;
z-index: 1;
margin-right: 15px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,132 +1,126 @@
<template>
<div @click="$emit('click')">
<div class="x-friend-item">
<template v-if="isLocalFavorite ? favorite.name : favorite.ref">
<div class="avatar">
<img :src="smallThumbnail" loading="lazy" />
<div :class="cardClasses" @click="$emit('click')">
<template v-if="localFavFakeRef">
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar" :class="{ 'is-empty': !localFavFakeRef.thumbnailImageUrl }">
<img v-if="localFavFakeRef.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="localFavFakeRef.name"></span>
<span class="extra" v-text="localFavFakeRef.authorName"></span>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<span class="name">{{ localFavFakeRef.name }}</span>
<span class="favorites-search-card__badges">
<el-tooltip
v-if="favorite.deleted"
placement="top"
:content="t('view.favorite.unavailable_tooltip')">
<i class="ri-error-warning-line"></i>
</el-tooltip>
<el-tooltip
v-if="!isLocalFavorite && favorite.ref?.releaseStatus === 'private'"
placement="top"
:content="t('view.favorite.private')">
<i class="ri-lock-line"></i>
</el-tooltip>
</span>
</div>
<span class="extra">{{ localFavFakeRef.authorName }}</span>
</div>
<div v-if="editFavoritesMode">
<FavoritesMoveDropdown
:favoriteGroup="favoriteAvatarGroups"
:currentFavorite="props.favorite"
:currentGroup="group"
type="avatar" />
<el-button v-if="!isLocalFavorite" type="text" size="small" style="margin-left: 5px" @click.stop>
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div
v-if="!isLocalFavorite"
class="favorites-search-card__action favorites-search-card__action--checkbox"
@click.stop>
<el-checkbox v-model="isSelected"></el-checkbox>
</el-button>
</div>
<template v-else-if="!isLocalFavorite">
<el-tooltip
v-if="favorite.deleted"
placement="left"
:content="t('view.favorite.unavailable_tooltip')"
:teleported="false">
<el-icon><Warning /></el-icon>
</el-tooltip>
<el-tooltip
v-if="favorite.ref.releaseStatus === 'private'"
placement="left"
:content="t('view.favorite.private')"
:teleported="false">
<el-icon><Warning /></el-icon>
</el-tooltip>
<el-tooltip
v-if="favorite.ref.releaseStatus !== 'private' && !favorite.deleted"
placement="left"
:content="t('view.favorite.select_avatar_tooltip')"
:teleported="false">
<el-button
:disabled="currentUser.currentAvatar === favorite.id"
size="small"
:icon="Check"
circle
style="margin-left: 5px"
@click.stop="selectAvatarWithConfirmation(favorite.id)"></el-button>
</el-tooltip>
<el-tooltip placement="right" :content="t('view.favorite.unfavorite_tooltip')" :teleported="false">
<el-button
v-if="shiftHeld"
size="small"
:icon="Close"
circle
style="color: #f56c6c; margin-left: 5px"
@click.stop="deleteFavorite(favorite.id)"></el-button>
<el-button
v-else
type="default"
:icon="Star"
size="small"
circle
style="margin-left: 5px"
@click.stop="showFavoriteDialog('avatar', favorite.id)"></el-button>
</el-tooltip>
</div>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action favorites-search-card__action--full" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteAvatarGroups"
:currentFavorite="props.favorite"
:currentGroup="group"
class="favorites-search-card__dropdown"
:is-local-favorite="isLocalFavorite"
type="avatar" />
</div>
<div class="favorites-search-card__action">
<el-tooltip
placement="left"
:content="
isLocalFavorite
? t('view.favorite.delete_tooltip')
: t('view.favorite.unfavorite_tooltip')
">
<el-button
size="small"
circle
class="favorites-search-card__action-btn"
:type="isLocalFavorite ? 'default' : 'default'"
@click.stop="handlePrimaryDeleteAction">
<i class="ri-delete-bin-line"></i>
</el-button>
</el-tooltip>
</div>
</div>
</template>
<template v-else>
<el-tooltip
placement="left"
:content="t('view.favorite.select_avatar_tooltip')"
:teleported="false">
<el-button
:disabled="currentUser.currentAvatar === favorite.id"
size="small"
circle
style="margin-left: 5px"
:icon="Check"
@click.stop="selectAvatarWithConfirmation(favorite.id)" />
</el-tooltip>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action" v-if="canSelectAvatar">
<el-tooltip placement="top" :content="t('view.favorite.select_avatar_tooltip')">
<el-button
:disabled="currentUser.currentAvatar === favorite.id"
size="small"
:icon="Check"
circle
class="favorites-search-card__action-btn"
@click.stop="selectAvatarWithConfirmation(favorite.id)" />
</el-tooltip>
</div>
<div class="favorites-search-card__action">
<el-tooltip placement="bottom" :content="t('view.favorite.unfavorite_tooltip')">
<el-button
v-if="showDangerUnfavorite"
size="small"
:icon="Close"
circle
class="favorites-search-card__action-btn"
type="danger"
@click.stop="handlePrimaryDeleteAction" />
<el-button
v-else
type="default"
:icon="Star"
size="small"
circle
class="favorites-search-card__action-btn"
@click.stop="showFavoriteDialog('avatar', favorite.id)" />
</el-tooltip>
</div>
</div>
</template>
<el-tooltip
v-if="isLocalFavorite"
placement="right"
:content="t('view.favorite.unfavorite_tooltip')"
:teleported="false">
<el-button
v-if="shiftHeld"
size="small"
:icon="Close"
circle
style="color: #f56c6c; margin-left: 5px"
@click.stop="removeLocalAvatarFavorite(favorite.id, favoriteGroupName)" />
<el-button
v-else
type="default"
:icon="Star"
size="small"
circle
style="margin-left: 5px"
@click.stop="showFavoriteDialog('avatar', favorite.id)" />
</el-tooltip>
</template>
<template v-else>
<div class="avatar"></div>
<div class="detail">
<span class="name" v-text="favorite.name || favorite.id"></span>
</div>
</template>
<template v-else>
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar is-empty"></div>
<div class="favorites-search-card__detail">
<span class="name">{{ favorite.name || favorite.id }}</span>
</div>
<el-button
v-if="isLocalFavorite"
type="text"
:icon="Close"
size="small"
style="margin-left: 5px"
@click.stop="removeLocalAvatarFavorite(favorite.id, favoriteGroupName)"></el-button>
<el-button
v-else
type="text"
:icon="Close"
size="small"
style="margin-left: 5px"
@click.stop="deleteFavorite(favorite.id)"></el-button>
</template>
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<el-button circle type="default" size="small" @click.stop="handlePrimaryDeleteAction">
<i class="ri-delete-bin-line"></i>
</el-button>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { Check, Close, Star, Warning } from '@element-plus/icons-vue';
import { Check, Close, Star } from '@element-plus/icons-vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -139,35 +133,74 @@
const props = defineProps({
favorite: Object,
group: [Object, String],
isLocalFavorite: Boolean
isLocalFavorite: Boolean,
editMode: { type: Boolean, default: false },
selected: { type: Boolean, default: false }
});
const emit = defineEmits(['click', 'handle-select']);
const emit = defineEmits(['click', 'toggle-select']);
const { t } = useI18n();
const { favoriteAvatarGroups, editFavoritesMode } = storeToRefs(useFavoriteStore());
const { favoriteAvatarGroups } = storeToRefs(useFavoriteStore());
const { removeLocalAvatarFavorite, showFavoriteDialog } = useFavoriteStore();
const { selectAvatarWithConfirmation } = useAvatarStore();
const { shiftHeld } = storeToRefs(useUiStore());
const { currentUser } = storeToRefs(useUserStore());
const isSelected = computed({
get: () => props.favorite.$selected,
set: (value) => emit('handle-select', value)
get: () => props.selected,
set: (value) => emit('toggle-select', value)
});
const localFavFakeRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite?.ref));
const cardClasses = computed(() => [
'favorites-search-card',
'favorites-search-card--avatar',
{
'is-selected': props.selected,
'is-edit-mode': props.editMode
}
]);
const smallThumbnail = computed(() => {
if (!localFavFakeRef.value?.thumbnailImageUrl) {
return '';
}
return localFavFakeRef.value.thumbnailImageUrl.replace('256', '128');
});
const localFavFakeRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite.ref));
const smallThumbnail = computed(
() => localFavFakeRef.value.thumbnailImageUrl?.replace('256', '128') || localFavFakeRef.value.thumbnailImageUrl
);
const favoriteGroupName = computed(() => {
if (typeof props.group === 'string') {
return props.group;
} else {
return props.group?.name;
}
return props.group?.name;
});
const canSelectAvatar = computed(() => {
if (props.isLocalFavorite) {
return true;
}
if (props.favorite?.deleted) {
return false;
}
return props.favorite?.ref?.releaseStatus !== 'private';
});
const showDangerUnfavorite = computed(() => {
if (props.isLocalFavorite) {
return shiftHeld.value;
}
return shiftHeld.value;
});
function handlePrimaryDeleteAction() {
if (props.isLocalFavorite) {
removeLocalAvatarFavorite(props.favorite.id, favoriteGroupName.value);
return;
}
deleteFavorite(props.favorite.id);
}
function deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({ objectId });
}

View File

@@ -1,44 +1,41 @@
<template>
<div @click="$emit('click')">
<div class="x-friend-item">
<div class="avatar">
<img :src="smallThumbnail" loading="lazy" />
<div :class="cardClasses" @click="$emit('click')">
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar" :class="{ 'is-empty': !favorite.thumbnailImageUrl }">
<img v-if="favorite.thumbnailImageUrl" :src="smallThumbnail" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="favorite.name"></span>
<span class="extra" v-text="favorite.authorName"></span>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<span class="name">{{ favorite.name }}</span>
</div>
<span class="extra">{{ favorite.authorName }}</span>
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action">
<el-tooltip placement="top" :content="t('view.favorite.select_avatar_tooltip')">
<el-button
:disabled="currentUser.currentAvatar === favorite.id"
size="small"
:icon="Check"
circle
class="favorites-search-card__action-btn"
@click.stop="selectAvatarWithConfirmation(favorite.id)" />
</el-tooltip>
</div>
<div class="favorites-search-card__action">
<el-tooltip placement="bottom" :content="t('view.favorite.favorite_tooltip')">
<el-button
type="default"
:icon="favoriteExists ? Star : StarFilled"
size="small"
circle
class="favorites-search-card__action-btn"
@click.stop="showFavoriteDialog('avatar', favorite.id)" />
</el-tooltip>
</div>
</div>
<el-tooltip placement="left" :content="t('view.favorite.select_avatar_tooltip')" :teleported="false">
<el-button
:disabled="currentUser.currentAvatar === favorite.id"
size="small"
:icon="Check"
circle
style="margin-left: 5px"
@click.stop="selectAvatarWithConfirmation(favorite.id)"></el-button>
</el-tooltip>
<template v-if="getCachedFavoritesByObjectId(favorite.id)">
<el-tooltip placement="right" content="Favorite" :teleported="false">
<el-button
type="default"
:icon="Star"
size="small"
circle
style="margin-left: 5px"
@click.stop="showFavoriteDialog('avatar', favorite.id)"></el-button>
</el-tooltip>
</template>
<template v-else>
<el-tooltip placement="right" content="Favorite" :teleported="false">
<el-button
type="default"
:icon="StarFilled"
size="small"
circle
style="margin-left: 5px"
@click.stop="showFavoriteDialog('avatar', favorite.id)"></el-button>
</el-tooltip>
</template>
</div>
</div>
</template>
@@ -66,7 +63,14 @@
defineEmits(['click']);
const favoriteExists = computed(() => Boolean(getCachedFavoritesByObjectId(props.favorite.id)));
const cardClasses = computed(() => ['favorites-search-card', 'favorites-search-card--avatar']);
const smallThumbnail = computed(() => {
return props.favorite.thumbnailImageUrl?.replace('256', '128') || props.favorite.thumbnailImageUrl;
if (!props.favorite.thumbnailImageUrl) {
return '';
}
return props.favorite.thumbnailImageUrl.replace('256', '128');
});
</script>

View File

@@ -1,475 +0,0 @@
<template>
<div>
<div style="display: flex; align-items: center; justify-content: space-between">
<div>
<el-button size="small" @click="showAvatarExportDialog">
{{ t('view.favorite.export') }}
</el-button>
<el-button size="small" style="margin-left: 5px" @click="showAvatarImportDialog">
{{ t('view.favorite.import') }}
</el-button>
</div>
<div style="display: flex; align-items: center; font-size: 13px; margin-right: 10px">
<span class="name" style="margin-right: 5px; line-height: 10px">
{{ t('view.favorite.sort_by') }}
</span>
<el-radio-group v-model="sortFav" style="margin-right: 12px">
<el-radio :label="false">
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
</el-radio>
<el-radio :label="true">
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
</el-radio>
</el-radio-group>
<el-input
v-model="avatarFavoriteSearch"
clearable
size="small"
:placeholder="t('view.favorite.avatars.search')"
style="width: 200px"
@input="searchAvatarFavorites" />
</div>
</div>
<div class="x-friend-list" style="margin-top: 10px">
<div
v-for="favorite in avatarFavoriteSearchResults"
:key="favorite.id"
style="display: inline-block; width: 300px; margin-right: 15px"
@click="showAvatarDialog(favorite.id)">
<div class="x-friend-item">
<template v-if="favorite.name">
<div class="avatar">
<img :src="favorite.thumbnailImageUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="favorite.name" />
<span class="extra" v-text="favorite.authorName" />
</div>
</template>
<template v-else>
<div class="avatar"></div>
<div class="detail">
<span class="name" v-text="favorite.id" />
</div>
</template>
</div>
</div>
</div>
<span style="display: block; margin-top: 20px">
{{ t('view.favorite.avatars.vrchat_favorites') }}
</span>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in favoriteAvatarGroups" :key="group.name">
<template #title>
<span style="font-weight: bold; font-size: 14px; margin-left: 10px" v-text="group.displayName" />
<span style="color: #909399; font-size: 12px; margin-left: 10px">
{{ group.count }}/{{ group.capacity }}
</span>
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Edit"
circle
style="margin-left: 10px"
@click.stop="changeFavoriteGroupName(group)" />
</el-tooltip>
<el-tooltip placement="right" :content="t('view.favorite.clear_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Delete"
circle
style="margin-left: 5px"
@click.stop="clearFavoriteGroup(group)" />
</el-tooltip>
</template>
<div v-if="group.count" class="x-friend-list" style="margin-top: 10px">
<FavoritesAvatarItem
v-for="favorite in groupedByGroupKeyFavoriteAvatars[group.key]"
:key="favorite.id"
:favorite="favorite"
:group="group"
style="display: inline-block; width: 300px; margin-right: 15px"
@handle-select="favorite.$selected = $event"
@click="showAvatarDialog(favorite.id)" />
</div>
<div
v-else
style="
padding-top: 25px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgb(144, 147, 153);
">
<span>No Data</span>
</div>
</el-collapse-item>
<el-collapse-item>
<template #title>
<span style="font-weight: bold; font-size: 14px; margin-left: 10px">Local History</span>
<span style="color: #909399; font-size: 12px; margin-left: 10px"
>{{ avatarHistory.length }}/100</span
>
<el-tooltip placement="right" content="Clear" :teleported="false">
<el-button
size="small"
:icon="Delete"
circle
style="margin-left: 5px"
@click.stop="promptClearAvatarHistory"></el-button>
</el-tooltip>
</template>
<div v-if="avatarHistory.length" class="x-friend-list" style="margin-top: 10px">
<FavoritesAvatarLocalHistoryItem
v-for="favorite in avatarHistory"
:key="favorite.id"
style="display: inline-block; width: 300px; margin-right: 15px"
:favorite="favorite"
@click="showAvatarDialog(favorite.id)" />
</div>
<div
v-else
style="
padding-top: 25px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgb(144, 147, 153);
">
<span>No Data</span>
</div>
</el-collapse-item>
<span style="display: block; margin-top: 20px">{{ t('view.favorite.avatars.local_favorites') }}</span>
<br />
<el-button size="small" :disabled="!isLocalUserVrcPlusSupporter" @click="promptNewLocalAvatarFavoriteGroup">
{{ t('view.favorite.avatars.new_group') }}
</el-button>
<el-button
v-if="!refreshingLocalFavorites"
size="small"
style="margin-left: 5px"
@click="refreshLocalAvatarFavorites">
{{ t('view.favorite.avatars.refresh') }}
</el-button>
<el-button v-else size="small" style="margin-left: 5px" @click="cancelLocalAvatarRefresh">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ t('view.favorite.avatars.cancel_refresh') }}</span>
</el-button>
<el-collapse-item v-for="group in localAvatarFavoriteGroups" :key="group">
<template #title v-if="localAvatarFavorites[group]">
<span :style="{ fontWeight: 'bold', fontSize: '14px', marginLeft: '10px' }">{{ group }}</span>
<span :style="{ color: '#909399', fontSize: '12px', marginLeft: '10px' }">{{
localAvatarFavGroupLength(group)
}}</span>
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Edit"
circle
:style="{ marginLeft: '5px' }"
@click.stop="promptLocalAvatarFavoriteGroupRename(group)"></el-button>
</el-tooltip>
<el-tooltip placement="right" :content="t('view.favorite.delete_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Delete"
circle
:style="{ marginLeft: '5px' }"
@click.stop="promptLocalAvatarFavoriteGroupDelete(group)"></el-button>
</el-tooltip>
</template>
<div v-if="localAvatarFavorites[group]?.length" class="x-friend-list" :style="{ marginTop: '10px' }">
<FavoritesAvatarItem
v-for="favorite in localAvatarFavorites[group]"
:key="favorite.id"
is-local-favorite
:style="{ display: 'inline-block', width: '300px', marginRight: '15px' }"
:favorite="favorite"
:group="group"
@handle-select="favorite.$selected = $event"
@click="showAvatarDialog(favorite.id)" />
</div>
<div
v-else
:style="{
paddingTop: '25px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgb(144, 147, 153)'
}">
<span>No Data</span>
</div>
</el-collapse-item>
</el-collapse>
<AvatarExportDialog v-model:avatarExportDialogVisible="avatarExportDialogVisible" />
</div>
</template>
<script setup>
import { Delete, Edit, Loading } from '@element-plus/icons-vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useAppearanceSettingsStore, useAvatarStore, useFavoriteStore, useUserStore } from '../../../stores';
import { avatarRequest, favoriteRequest } from '../../../api';
import AvatarExportDialog from '../dialogs/AvatarExportDialog.vue';
import FavoritesAvatarItem from './FavoritesAvatarItem.vue';
import FavoritesAvatarLocalHistoryItem from './FavoritesAvatarLocalHistoryItem.vue';
import * as workerTimers from 'worker-timers';
const emit = defineEmits(['change-favorite-group-name', 'refresh-local-avatar-favorites']);
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
const { setSortFavorites } = useAppearanceSettingsStore();
const { favoriteAvatars, favoriteAvatarGroups, localAvatarFavorites } = storeToRefs(useFavoriteStore());
const {
showAvatarImportDialog,
localAvatarFavGroupLength,
deleteLocalAvatarFavoriteGroup,
renameLocalAvatarFavoriteGroup,
newLocalAvatarFavoriteGroup,
localAvatarFavoritesList,
localAvatarFavoriteGroups
} = useFavoriteStore();
const { avatarHistory } = storeToRefs(useAvatarStore());
const { promptClearAvatarHistory, showAvatarDialog, applyAvatar } = useAvatarStore();
const { isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
const { t } = useI18n();
const avatarExportDialogVisible = ref(false);
const avatarFavoriteSearch = ref('');
const avatarFavoriteSearchResults = ref([]);
const refreshingLocalFavorites = ref(false);
const worker = ref(null);
const refreshCancelToken = ref(null);
const sortFav = computed({
get() {
return sortFavorites.value;
},
set() {
setSortFavorites();
}
});
const groupedByGroupKeyFavoriteAvatars = computed(() => {
const groupedByGroupKeyFavoriteAvatars = {};
favoriteAvatars.value.forEach((avatar) => {
if (avatar.groupKey) {
if (!groupedByGroupKeyFavoriteAvatars[avatar.groupKey]) {
groupedByGroupKeyFavoriteAvatars[avatar.groupKey] = [];
}
groupedByGroupKeyFavoriteAvatars[avatar.groupKey].push(avatar);
}
});
return groupedByGroupKeyFavoriteAvatars;
});
function searchAvatarFavorites() {
let ref = null;
const search = avatarFavoriteSearch.value.toLowerCase();
if (search.length < 3) {
avatarFavoriteSearchResults.value = [];
return;
}
const results = [];
for (let i = 0; i < localAvatarFavoriteGroups.length; ++i) {
const group = localAvatarFavoriteGroups[i];
if (!localAvatarFavorites.value[group]) {
continue;
}
for (let j = 0; j < localAvatarFavorites.value[group].length; ++j) {
ref = localAvatarFavorites.value[group][j];
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
}
for (let i = 0; i < favoriteAvatars.value.length; ++i) {
ref = favoriteAvatars.value[i].ref;
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
avatarFavoriteSearchResults.value = results;
}
function clearFavoriteGroup(ctx) {
ElMessageBox.confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info'
})
.then((action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
})
.catch(() => {});
}
function showAvatarExportDialog() {
avatarExportDialogVisible.value = true;
}
function changeFavoriteGroupName(group) {
emit('change-favorite-group-name', group);
}
function promptNewLocalAvatarFavoriteGroup() {
ElMessageBox.prompt(
t('prompt.new_local_favorite_group.description'),
t('prompt.new_local_favorite_group.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.new_local_favorite_group.ok'),
cancelButtonText: t('prompt.new_local_favorite_group.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.new_local_favorite_group.input_error')
}
)
.then(({ value }) => {
if (value) {
newLocalAvatarFavoriteGroup(value);
}
})
.catch(() => {});
}
function promptLocalAvatarFavoriteGroupRename(group) {
ElMessageBox.prompt(
t('prompt.local_favorite_group_rename.description'),
t('prompt.local_favorite_group_rename.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.local_favorite_group_rename.save'),
cancelButtonText: t('prompt.local_favorite_group_rename.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.local_favorite_group_rename.input_error'),
inputValue: group
}
)
.then(({ value }) => {
if (value) {
renameLocalAvatarFavoriteGroup(value, group);
}
})
.catch(() => {});
}
function promptLocalAvatarFavoriteGroupDelete(group) {
ElMessageBox.confirm(`Delete Group? ${group}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info'
})
.then((action) => {
if (action === 'confirm') {
deleteLocalAvatarFavoriteGroup(group);
}
})
.catch(() => {});
}
async function refreshLocalAvatarFavorites() {
if (refreshingLocalFavorites.value) {
return;
}
refreshingLocalFavorites.value = true;
const token = {
cancelled: false,
resolve: null
};
refreshCancelToken.value = token;
try {
for (const avatarId of localAvatarFavoritesList) {
if (token.cancelled) {
break;
}
try {
const args = await avatarRequest.getAvatar({
avatarId
});
applyAvatar(args.json);
} catch (err) {
console.error(err);
}
if (token.cancelled) {
break;
}
await new Promise((resolve) => {
token.resolve = resolve;
worker.value = workerTimers.setTimeout(() => {
worker.value = null;
resolve();
}, 1000);
});
}
} finally {
if (worker.value) {
workerTimers.clearTimeout(worker.value);
worker.value = null;
}
if (refreshCancelToken.value === token) {
refreshCancelToken.value = null;
}
refreshingLocalFavorites.value = false;
}
}
function cancelLocalAvatarRefresh() {
if (!refreshingLocalFavorites.value) {
return;
}
if (refreshCancelToken.value) {
refreshCancelToken.value.cancelled = true;
if (typeof refreshCancelToken.value.resolve === 'function') {
refreshCancelToken.value.resolve();
}
}
if (worker.value) {
workerTimers.clearTimeout(worker.value);
worker.value = null;
}
refreshingLocalFavorites.value = false;
}
onBeforeUnmount(() => {
cancelLocalAvatarRefresh();
});
</script>

View File

@@ -1,85 +1,134 @@
<template>
<div @click="$emit('click')">
<div class="x-friend-item">
<template v-if="favorite.ref">
<div class="avatar" :class="userStatusClass(favorite.ref)">
<div :class="cardClasses" @click="$emit('click')">
<template v-if="favorite.ref">
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar">
<img :src="userImage(favorite.ref, true)" loading="lazy" />
</div>
<div class="detail">
<span
class="name"
:style="{ color: favorite.ref.$userColour }"
v-text="favorite.ref.displayName"></span>
<Location
class="extra"
v-if="favorite.ref.location !== 'offline'"
:location="favorite.ref.location"
:traveling="favorite.ref.travelingToLocation"
:link="false" />
<span v-else v-text="favorite.ref.statusDescription"></span>
<div class="favorites-search-card__detail">
<div class="favorites-search-card__title">
<span class="name" :style="displayNameStyle">{{ favorite.ref.displayName }}</span>
</div>
<div v-if="favorite.ref.location !== 'offline'" class="favorites-search-card__location">
<Location
:location="favorite.ref.location"
:traveling="favorite.ref.travelingToLocation"
:link="false" />
</div>
<span v-else class="extra">{{ favorite.ref.statusDescription }}</span>
</div>
<div v-if="editFavoritesMode">
<FavoritesMoveDropdown
:favoriteGroup="favoriteFriendGroups"
:currentGroup="group"
:currentFavorite="favorite"
type="friend" />
<el-button type="text" size="small" style="margin-left: 5px" @click.stop>
<el-checkbox v-model="favorite.$selected"></el-checkbox>
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div class="favorites-search-card__action favorites-search-card__action--checkbox" @click.stop>
<el-checkbox v-model="isSelected"></el-checkbox>
</div>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action favorites-search-card__action--full" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteFriendGroups"
:currentGroup="group"
:currentFavorite="favorite"
class="favorites-search-card__dropdown"
type="friend" />
</div>
<div class="favorites-search-card__action">
<el-tooltip placement="left" :content="t('view.favorite.unfavorite_tooltip')">
<el-button
size="small"
circle
class="favorites-search-card__action-btn"
type="default"
@click.stop="handleDeleteFavorite">
<i class="ri-delete-bin-line"></i>
</el-button>
</el-tooltip>
</div>
</div>
</template>
<template v-else>
<div class="favorites-search-card__action">
<el-tooltip placement="right" :content="t('view.favorite.unfavorite_tooltip')">
<el-button
size="small"
:icon="Star"
circle
class="favorites-search-card__action-btn"
@click.stop="showFavoriteDialog('friend', favorite.id)" />
</el-tooltip>
</div>
</template>
</div>
</template>
<template v-else>
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar is-empty"></div>
<div class="favorites-search-card__detail">
<span class="name">{{ favorite.name || favorite.id }}</span>
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<el-button circle type="default" size="small" @click.stop="handleDeleteFavorite">
<i class="ri-delete-bin-line"></i>
</el-button>
</div>
<template v-else>
<el-tooltip placement="right" :content="t('view.favorite.unfavorite_tooltip')" :teleported="false">
<el-button
type="default"
:icon="Star"
size="small"
circle
style="margin-left: 5px"
@click.stop="showFavoriteDialog('friend', favorite.id)"></el-button>
</el-tooltip>
</template>
</template>
<template v-else>
<div class="avatar"></div>
<div class="detail">
<span v-text="favorite.name || favorite.id"></span>
</div>
<el-button
type="text"
:icon="Close"
size="small"
style="margin-left: 5px"
@click.stop="deleteFavorite(favorite.id)"></el-button>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { Close, Star } from '@element-plus/icons-vue';
import { Star } from '@element-plus/icons-vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { userImage, userStatusClass } from '../../../shared/utils';
import { favoriteRequest } from '../../../api';
import { useFavoriteStore } from '../../../stores';
import { userImage } from '../../../shared/utils';
import FavoritesMoveDropdown from './FavoritesMoveDropdown.vue';
defineProps({
const props = defineProps({
favorite: { type: Object, required: true },
group: { type: Object, required: true }
group: { type: Object, default: null },
editMode: { type: Boolean, default: false },
selected: { type: Boolean, default: false }
});
defineEmits(['click']);
const emit = defineEmits(['click', 'toggle-select']);
const { favoriteFriendGroups, editFavoritesMode } = storeToRefs(useFavoriteStore());
const { favoriteFriendGroups } = storeToRefs(useFavoriteStore());
const { showFavoriteDialog } = useFavoriteStore();
const { t } = useI18n();
function deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({ objectId });
const isSelected = computed({
get: () => props.selected,
set: (value) => emit('toggle-select', value)
});
const cardClasses = computed(() => [
'favorites-search-card',
'favorites-search-card--friend',
{
'is-selected': props.selected,
'is-edit-mode': props.editMode
}
]);
const displayNameStyle = computed(() => {
if (props.favorite?.ref?.$userColour) {
return {
color: props.favorite.ref.$userColour
};
}
return {};
});
function handleDeleteFavorite() {
favoriteRequest.deleteFavorite({
objectId: props.favorite.id
});
}
</script>

View File

@@ -1,133 +0,0 @@
<template>
<div>
<div style="display: flex; align-items: center; justify-content: space-between">
<div>
<el-button size="small" @click="showFriendExportDialog">{{ t('view.favorite.export') }}</el-button>
<el-button size="small" style="margin-left: 5px" @click="showFriendImportDialog">{{
t('view.favorite.import')
}}</el-button>
</div>
<div style="display: flex; align-items: center; font-size: 13px; margin-right: 10px">
<span class="name" style="margin-right: 5px; line-height: 10px">{{ t('view.favorite.sort_by') }}</span>
<el-radio-group v-model="sortFav">
<el-radio :label="false">{{
t('view.settings.appearance.appearance.sort_favorite_by_name')
}}</el-radio>
<el-radio :label="true">{{
t('view.settings.appearance.appearance.sort_favorite_by_date')
}}</el-radio>
</el-radio-group>
</div>
</div>
<span style="display: block; margin-top: 30px">{{ t('view.favorite.avatars.vrchat_favorites') }}</span>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in favoriteFriendGroups" :key="group.name">
<template #title>
<span
style="font-weight: bold; font-size: 14px; margin-left: 10px"
v-text="group.displayName"></span>
<span style="color: #909399; font-size: 12px; margin-left: 10px"
>{{ group.count }}/{{ group.capacity }}</span
>
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Edit"
circle
style="margin-left: 10px"
@click.stop="changeFavoriteGroupName(group)"></el-button>
</el-tooltip>
<el-tooltip placement="right" :content="t('view.favorite.clear_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Delete"
circle
style="margin-left: 5px"
@click.stop="clearFavoriteGroup(group)"></el-button>
</el-tooltip>
</template>
<div v-if="group.count" class="x-friend-list" style="margin-top: 10px">
<FavoritesFriendItem
v-for="favorite in groupedByGroupKeyFavoriteFriends[group.key]"
:key="favorite.id"
style="display: inline-block; width: 300px; margin-right: 15px"
:favorite="favorite"
:group="group"
@click="showUserDialog(favorite.id)" />
</div>
<div
v-else
style="
padding-top: 25px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgb(144, 147, 153);
">
<span>No Data</span>
</div>
</el-collapse-item>
</el-collapse>
<FriendExportDialog v-model:friendExportDialogVisible="friendExportDialogVisible" />
</div>
</template>
<script setup>
import { Delete, Edit } from '@element-plus/icons-vue';
import { computed, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useAppearanceSettingsStore, useFavoriteStore, useUserStore } from '../../../stores';
import { favoriteRequest } from '../../../api';
import FavoritesFriendItem from './FavoritesFriendItem.vue';
import FriendExportDialog from '../dialogs/FriendExportDialog.vue';
const emit = defineEmits(['change-favorite-group-name']);
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
const { setSortFavorites } = useAppearanceSettingsStore();
const { showUserDialog } = useUserStore();
const { favoriteFriendGroups, groupedByGroupKeyFavoriteFriends } = storeToRefs(useFavoriteStore());
const { showFriendImportDialog } = useFavoriteStore();
const { t } = useI18n();
const friendExportDialogVisible = ref(false);
const sortFav = computed({
get() {
return sortFavorites.value;
},
set() {
setSortFavorites();
}
});
function showFriendExportDialog() {
friendExportDialogVisible.value = true;
}
function clearFavoriteGroup(ctx) {
ElMessageBox.confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info'
})
.then((action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
})
.catch(() => {});
}
function changeFavoriteGroupName(group) {
emit('change-favorite-group-name', group);
}
</script>

View File

@@ -1,68 +1,99 @@
<template>
<div class="fav-world-item" @click="$emit('click')">
<div class="x-friend-item">
<template v-if="favorite.ref">
<div class="avatar" v-once>
<img :src="smallThumbnail" loading="lazy" decoding="async" fetchpriority="low" />
<div :class="cardClasses" @click="$emit('click')">
<template v-if="favorite.ref">
<div class="favorites-search-card__content">
<div
class="favorites-search-card__avatar"
:class="{ 'is-empty': !favorite.ref.thumbnailImageUrl }"
v-once>
<img
v-if="favorite.ref.thumbnailImageUrl"
:src="smallThumbnail"
loading="lazy"
decoding="async"
fetchpriority="low" />
</div>
<div class="detail" v-once>
<span class="name">{{ props.favorite.ref.name }}</span>
<span v-if="props.favorite.ref.occupants" class="extra">
{{ props.favorite.ref.authorName }} ({{ props.favorite.ref.occupants }})
<div class="favorites-search-card__detail" v-once>
<div class="favorites-search-card__title">
<span class="name">{{ props.favorite.ref.name }}</span>
<span
v-if="favorite.deleted || favorite.ref.releaseStatus === 'private'"
class="favorites-search-card__badges">
<i
v-if="favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')"
class="ri-error-warning-line"></i>
<i
v-if="favorite.ref.releaseStatus === 'private'"
:title="t('view.favorite.private')"
class="ri-lock-line"></i>
</span>
</div>
<span class="extra">
{{ props.favorite.ref.authorName }}
<template v-if="props.favorite.ref.occupants"> ({{ props.favorite.ref.occupants }}) </template>
</span>
<span v-else class="extra">{{ props.favorite.ref.authorName }}</span>
</div>
<div v-if="editFavoritesMode">
<FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite"
:currentGroup="group"
type="world" />
<el-button type="text" size="small" @click.stop style="margin-left: 5px">
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div class="favorites-search-card__action favorites-search-card__action--checkbox" @click.stop>
<el-checkbox v-model="isSelected"></el-checkbox>
</div>
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action favorites-search-card__action--full" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite"
:currentGroup="group"
class="favorites-search-card__dropdown"
type="world" />
</div>
<div class="favorites-search-card__action">
<el-button
size="small"
circle
class="favorites-search-card__action-btn"
type="default"
@click.stop="handleDeleteFavorite">
<i class="ri-delete-bin-line"></i>
</el-button>
</div>
</div>
</template>
<template v-else>
<div class="favorites-search-card__action">
<el-tooltip placement="top" :content="inviteOrLaunchText">
<el-button
size="small"
:icon="Message"
class="favorites-search-card__action-btn"
@click.stop="newInstanceSelfInvite(favorite.id)"
circle />
</el-tooltip>
</div>
</template>
</div>
</template>
<template v-else>
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar is-empty"></div>
<div class="favorites-search-card__detail" v-once>
<span class="name">{{ favorite.name || favorite.id }}</span>
<i
v-if="favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')"
class="ri-error-warning-line"></i>
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<el-button circle type="default" size="small" @click.stop="handleDeleteFavorite">
<i class="ri-delete-bin-line"></i>
</el-button>
</div>
<template v-else>
<i
v-if="favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')"
class="ri-error-warning-line"></i>
<i
v-if="favorite.ref.releaseStatus === 'private'"
:title="t('view.favorite.private')"
class="ri-lock-line"></i>
<el-tooltip placement="left" :content="inviteOrLaunchText" :teleported="false">
<el-button
size="small"
:icon="Message"
style="margin-left: 5px"
@click.stop="newInstanceSelfInvite(favorite.id)"
circle></el-button>
</el-tooltip>
<el-button
size="small"
circle
style="margin-left: 5px"
type="default"
@click.stop="showFavoriteDialog('world', favorite.id)"
><i class="ri-delete-bin-line"></i
></el-button>
</template>
</template>
<template v-else>
<div class="avatar"></div>
<div class="detail" v-once>
<span>{{ favorite.name || favorite.id }}</span>
<i
v-if="favorite.deleted"
:title="t('view.favorite.unavailable_tooltip')"
class="ri-error-warning-line"></i>
<el-button type="text" size="small" style="margin-left: 5px" @click.stop="handleDeleteFavorite"
><i class="ri-delete-bin-line"></i
></el-button>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
@@ -80,21 +111,31 @@
const props = defineProps({
group: [Object, String],
favorite: Object,
isLocalFavorite: { type: Boolean, default: false }
isLocalFavorite: { type: Boolean, default: false },
editMode: { type: Boolean, default: false },
selected: { type: Boolean, default: false }
});
const emit = defineEmits(['handle-select', 'remove-local-world-favorite', 'click']);
const { favoriteWorldGroups, editFavoritesMode } = storeToRefs(useFavoriteStore());
const { showFavoriteDialog } = useFavoriteStore();
const emit = defineEmits(['toggle-select', 'remove-local-world-favorite', 'click']);
const { favoriteWorldGroups } = storeToRefs(useFavoriteStore());
const { newInstanceSelfInvite } = useInviteStore();
const { t } = useI18n();
const { canOpenInstanceInGame } = useInviteStore();
const isSelected = computed({
get: () => props.favorite.$selected,
set: (value) => emit('handle-select', value)
get: () => props.selected,
set: (value) => emit('toggle-select', value)
});
const cardClasses = computed(() => [
'favorites-search-card',
'favorites-search-card--world',
{
'is-selected': props.selected,
'is-edit-mode': props.editMode
}
]);
const smallThumbnail = computed(() => {
const url = props.favorite.ref.thumbnailImageUrl?.replace('256', '128');
return url || props.favorite.ref.thumbnailImageUrl;
@@ -119,11 +160,4 @@
}
</script>
<style scoped>
.fav-world-item {
display: inline-block;
width: 300px;
margin-right: 15px;
height: 53px;
}
</style>
<style scoped></style>

View File

@@ -1,70 +1,82 @@
<template>
<div class="fav-world-item" @click="$emit('click')">
<div class="x-friend-item">
<template v-if="favorite.name">
<div class="avatar" v-once>
<img :src="smallThumbnail" loading="lazy" decoding="async" fetchpriority="low" />
<div :class="cardClasses" @click="$emit('click')">
<template v-if="favorite.name">
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar" :class="{ 'is-empty': !favorite.thumbnailImageUrl }" v-once>
<img
v-if="favorite.thumbnailImageUrl"
:src="smallThumbnail"
loading="lazy"
decoding="async"
fetchpriority="low" />
</div>
<div class="detail" v-once>
<span class="name">{{ props.favorite.name }}</span>
<span v-if="props.favorite.occupants" class="extra">
{{ props.favorite.authorName }} ({{ props.favorite.occupants }})
<div class="favorites-search-card__detail" v-once>
<div class="favorites-search-card__title">
<span class="name">{{ props.favorite.name }}</span>
</div>
<span class="extra">
{{ props.favorite.authorName }}
<template v-if="props.favorite.occupants"> ({{ props.favorite.occupants }}) </template>
</span>
<span v-else class="extra">{{ props.favorite.authorName }}</span>
</div>
<FavoritesMoveDropdown
v-if="editFavoritesMode"
:favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite"
isLocalFavorite
type="world" />
<template v-else>
<el-tooltip placement="left" :content="inviteOrLaunchText" :teleported="false">
<el-button
size="small"
:icon="Message"
style="margin-left: 5px"
@click.stop="newInstanceSelfInvite(favorite.id)"
circle></el-button>
</el-tooltip>
<el-button
v-if="shiftHeld"
size="small"
:icon="Close"
circle
style="color: #f56c6c; margin-left: 5px"
@click.stop="$emit('remove-local-world-favorite', favorite.id, group)"
><i class="ri-delete-bin-line"></i
></el-button>
<el-button
v-else
size="small"
circle
style="margin-left: 5px"
type="default"
@click.stop="showFavoriteDialog('world', favorite.id)"
><i class="ri-delete-bin-line"></i
></el-button>
</div>
<div class="favorites-search-card__actions">
<template v-if="editMode">
<div class="favorites-search-card__action-group">
<div class="favorites-search-card__action favorites-search-card__action--full" @click.stop>
<FavoritesMoveDropdown
:favoriteGroup="favoriteWorldGroups"
:currentFavorite="props.favorite"
class="favorites-search-card__dropdown"
isLocalFavorite
type="world" />
</div>
<div class="favorites-search-card__action">
<el-button
size="small"
circle
class="favorites-search-card__action-btn"
:type="deleteButtonType"
@click.stop="handlePrimaryDeleteAction">
<i class="ri-delete-bin-line"></i>
</el-button>
</div>
</div>
</template>
</template>
<template v-else>
<div class="avatar"></div>
<div class="detail" v-once>
<span>{{ favorite.name || favorite.id }}</span>
<el-button
type="text"
:icon="Close"
size="small"
style="margin-left: 5px"
@click.stop="handleDeleteFavorite"></el-button>
<template v-else>
<div class="favorites-search-card__action">
<el-tooltip placement="top" :content="inviteOrLaunchText">
<el-button
size="small"
:icon="Message"
class="favorites-search-card__action-btn"
@click.stop="newInstanceSelfInvite(favorite.id)"
circle />
</el-tooltip>
</div>
</template>
</div>
</template>
<template v-else>
<div class="favorites-search-card__content">
<div class="favorites-search-card__avatar is-empty"></div>
<div class="favorites-search-card__detail" v-once>
<span class="name">{{ favorite.name || favorite.id }}</span>
</div>
</template>
</div>
</div>
<div class="favorites-search-card__actions">
<div class="favorites-search-card__action">
<el-button circle type="default" size="small" @click.stop="handleDeleteFavorite">
<i class="ri-delete-bin-line"></i>
</el-button>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { Close, Message } from '@element-plus/icons-vue';
import { Message } from '@element-plus/icons-vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -75,38 +87,50 @@
const props = defineProps({
group: [Object, String],
favorite: Object
favorite: Object,
editMode: { type: Boolean, default: false }
});
const emit = defineEmits(['handle-select', 'remove-local-world-favorite', 'click']);
const { favoriteWorldGroups, editFavoritesMode } = storeToRefs(useFavoriteStore());
const emit = defineEmits(['remove-local-world-favorite', 'click']);
const { favoriteWorldGroups } = storeToRefs(useFavoriteStore());
const { showFavoriteDialog } = useFavoriteStore();
const { newInstanceSelfInvite } = useInviteStore();
const { shiftHeld } = storeToRefs(useUiStore());
const { t } = useI18n();
const { canOpenInstanceInGame } = useInviteStore();
const cardClasses = computed(() => [
'favorites-search-card',
'favorites-search-card--world',
{
'is-edit-mode': props.editMode
}
]);
const smallThumbnail = computed(() => {
const url = props.favorite.thumbnailImageUrl?.replace('256', '128');
return url || props.favorite.thumbnailImageUrl;
});
const deleteButtonType = computed(() => (shiftHeld.value ? 'danger' : 'default'));
const inviteOrLaunchText = computed(() => {
return canOpenInstanceInGame
? t('dialog.world.actions.new_instance_and_open_ingame')
: t('dialog.world.actions.new_instance_and_self_invite');
});
function handlePrimaryDeleteAction() {
if (shiftHeld.value) {
emit('remove-local-world-favorite', props.favorite.id, props.group);
return;
}
showFavoriteDialog('world', props.favorite.id);
}
function handleDeleteFavorite() {
emit('remove-local-world-favorite', props.favorite.id, props.group);
}
</script>
<style scoped>
.fav-world-item {
display: inline-block;
width: 300px;
margin-right: 15px;
height: 53px;
}
</style>
<style scoped></style>

View File

@@ -1,532 +0,0 @@
<template>
<div>
<div style="display: flex; align-items: center; justify-content: space-between">
<div>
<el-button size="small" @click="showExportDialog">{{ t('view.favorite.export') }}</el-button>
<el-button size="small" style="margin-left: 5px" @click="showWorldImportDialog">{{
t('view.favorite.import')
}}</el-button>
</div>
<div style="display: flex; align-items: center; font-size: 13px; margin-right: 10px">
<span class="name" style="margin-right: 5px; line-height: 10px">{{ t('view.favorite.sort_by') }}</span>
<el-radio-group v-model="sortFav" style="margin-right: 12px">
<el-radio :label="false">{{
t('view.settings.appearance.appearance.sort_favorite_by_name')
}}</el-radio>
<el-radio :label="true">{{
t('view.settings.appearance.appearance.sort_favorite_by_date')
}}</el-radio>
</el-radio-group>
<el-input
v-model="worldFavoriteSearch"
clearable
size="small"
:placeholder="t('view.favorite.worlds.search')"
style="width: 200px"
@input="searchWorldFavorites" />
</div>
</div>
<div class="x-friend-list" style="margin-top: 10px">
<div
v-for="favorite in worldFavoriteSearchResults"
:key="favorite.id"
style="display: inline-block; width: 300px; margin-right: 15px"
@click="showWorldDialog(favorite.id)">
<div class="x-friend-item">
<template v-if="favorite.name">
<div class="avatar">
<img :src="favorite.thumbnailImageUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="favorite.name"></span>
<span v-if="favorite.occupants" class="extra"
>{{ favorite.authorName }} ({{ favorite.occupants }})</span
>
<span v-else class="extra" v-text="favorite.authorName"></span>
</div>
</template>
<template v-else>
<div class="avatar"></div>
<div class="detail">
<span v-text="favorite.id"></span>
</div>
</template>
</div>
</div>
</div>
<span style="display: block; margin-top: 20px">{{ t('view.favorite.worlds.vrchat_favorites') }}</span>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in favoriteWorldGroups" :key="group.name">
<template #title>
<div style="display: flex; align-items: center">
<span
style="font-weight: bold; font-size: 14px; margin-left: 10px"
v-text="group.displayName" />
<el-tag
style="margin: 1px 0 0 5px"
size="small"
:type="userFavoriteWorldsStatusForFavTab(group.visibility)"
effect="plain"
>{{ group.visibility.charAt(0).toUpperCase() + group.visibility.slice(1) }}</el-tag
>
<span style="color: #909399; font-size: 12px; margin-left: 10px"
>{{ group.count }}/{{ group.capacity }}</span
><el-tooltip
placement="top"
:content="t('view.favorite.visibility_tooltip')"
:teleported="false">
<el-dropdown trigger="click" size="small" style="margin-left: 10px" :persistent="false">
<el-button type="default" :icon="View" size="small" circle @click.stop />
<template #dropdown>
<el-dropdown-menu>
<template v-for="visibility in worldGroupVisibilityOptions" :key="visibility">
<el-dropdown-item
v-if="group.visibility !== visibility"
style="display: block; margin: 10px 0"
@click="changeWorldGroupVisibility(group.name, visibility)"
>{{
visibility.charAt(0).toUpperCase() + visibility.slice(1)
}}</el-dropdown-item
>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Edit"
circle
style="margin-left: 5px"
@click.stop="changeFavoriteGroupName(group)" />
</el-tooltip>
<el-tooltip placement="right" :content="t('view.favorite.clear_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Delete"
circle
style="margin-left: 5px"
@click.stop="clearFavoriteGroup(group)" />
</el-tooltip>
</div>
</template>
<div v-if="group.count" class="x-friend-list" style="margin-top: 10px">
<el-scrollbar height="700px" @end-reached="worldFavoritesLoadMore">
<FavoritesWorldItem
v-for="favorite in sliceWorldFavorites(group.key)"
:key="favorite.id"
:group="group"
:favorite="favorite"
@click="showWorldDialog(favorite.id)"
@handle-select="favorite.$selected = $event" />
</el-scrollbar>
</div>
<div
v-else
style="
padding-top: 25px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgb(144, 147, 153);
">
<span>No Data</span>
</div>
</el-collapse-item>
</el-collapse>
<span style="display: block; margin-top: 20px">{{ t('view.favorite.worlds.local_favorites') }}</span>
<br />
<el-button size="small" @click="promptNewLocalWorldFavoriteGroup">{{
t('view.favorite.worlds.new_group')
}}</el-button>
<el-button
v-if="!refreshingLocalFavorites"
size="small"
style="margin-left: 5px"
@click="refreshLocalWorldFavorites"
>{{ t('view.favorite.worlds.refresh') }}</el-button
>
<el-button v-else size="small" style="margin-left: 5px" @click="cancelLocalWorldRefresh">
<el-icon style="margin-right: 5px"><Loading /></el-icon>
<span>{{ t('view.favorite.worlds.cancel_refresh') }}</span>
</el-button>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in localWorldFavoriteGroups" :key="group">
<template #title>
<span style="font-weight: bold; font-size: 14px; margin-left: 10px" v-text="group" />
<span style="color: #909399; font-size: 12px; margin-left: 10px">{{
localWorldFavGroupLength(group)
}}</span>
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Edit"
circle
style="margin-left: 10px"
@click.stop="promptLocalWorldFavoriteGroupRename(group)" />
</el-tooltip>
<el-tooltip placement="right" :content="t('view.favorite.delete_tooltip')" :teleported="false">
<el-button
size="small"
:icon="Delete"
circle
style="margin-left: 5px"
@click.stop="promptLocalWorldFavoriteGroupDelete(group)" />
</el-tooltip>
</template>
<div v-if="localWorldFavorites[group]?.length" class="x-friend-list" style="margin-top: 10px">
<el-scrollbar height="700px" @end-reached="localWorldFavoritesLoadMore">
<FavoritesWorldLocalItem
v-for="favorite in sliceLocalWorldFavorites(group)"
:key="favorite.id"
:group="group"
:favorite="favorite"
@click="showWorldDialog(favorite.id)"
@remove-local-world-favorite="removeLocalWorldFavorite"
/></el-scrollbar>
</div>
<div
v-else
style="
padding-top: 25px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgb(144, 147, 153);
">
<span>No Data</span>
</div>
</el-collapse-item>
</el-collapse>
<WorldExportDialog v-model:worldExportDialogVisible="worldExportDialogVisible" />
</div>
</template>
<script setup>
import { Delete, Edit, Loading, View } from '@element-plus/icons-vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useAppearanceSettingsStore, useFavoriteStore, useWorldStore } from '../../../stores';
import { favoriteRequest, worldRequest } from '../../../api';
import FavoritesWorldItem from './FavoritesWorldItem.vue';
import FavoritesWorldLocalItem from './FavoritesWorldLocalItem.vue';
import WorldExportDialog from '../dialogs/WorldExportDialog.vue';
import * as workerTimers from 'worker-timers';
const emit = defineEmits([
'change-favorite-group-name',
'save-sort-favorites-option',
'refresh-local-world-favorite'
]);
const { t } = useI18n();
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
const { setSortFavorites } = useAppearanceSettingsStore();
const { favoriteWorlds, favoriteWorldGroups, localWorldFavorites } = storeToRefs(useFavoriteStore());
const {
showWorldImportDialog,
localWorldFavGroupLength,
deleteLocalWorldFavoriteGroup,
renameLocalWorldFavoriteGroup,
removeLocalWorldFavorite,
newLocalWorldFavoriteGroup,
handleFavoriteGroup,
localWorldFavoritesList,
localWorldFavoriteGroups
} = useFavoriteStore();
const { showWorldDialog } = useWorldStore();
const worldGroupVisibilityOptions = ref(['private', 'friends', 'public']);
const worldExportDialogVisible = ref(false);
const worldFavoriteSearch = ref('');
const worldFavoriteSearchResults = ref([]);
const sliceLocalWorldFavoritesLoadMoreNumber = ref(60);
const sliceWorldFavoritesLoadMoreNumber = ref(60);
const refreshingLocalFavorites = ref(false);
const worker = ref(null);
const refreshCancelToken = ref(null);
const sliceLocalWorldFavorites = computed(() => {
return (group) => {
return localWorldFavorites.value[group].slice(0, sliceLocalWorldFavoritesLoadMoreNumber.value);
};
});
const sliceWorldFavorites = computed(() => {
return (group) => {
const groupedByGroupKeyFavoriteWorlds = {};
favoriteWorlds.value.forEach((world) => {
if (world.groupKey) {
if (!groupedByGroupKeyFavoriteWorlds[world.groupKey]) {
groupedByGroupKeyFavoriteWorlds[world.groupKey] = [];
}
groupedByGroupKeyFavoriteWorlds[world.groupKey].push(world);
}
});
if (groupedByGroupKeyFavoriteWorlds[group]) {
return groupedByGroupKeyFavoriteWorlds[group].slice(0, sliceWorldFavoritesLoadMoreNumber.value);
}
return [];
};
});
const sortFav = computed({
get() {
return sortFavorites.value;
},
set() {
setSortFavorites();
}
});
function localWorldFavoritesLoadMore(direction) {
if (direction === 'bottom') {
sliceLocalWorldFavoritesLoadMoreNumber.value += 20;
}
}
function worldFavoritesLoadMore(direction) {
if (direction === 'bottom') {
sliceWorldFavoritesLoadMoreNumber.value += 20;
}
}
function showExportDialog() {
worldExportDialogVisible.value = true;
}
function userFavoriteWorldsStatusForFavTab(visibility) {
if (visibility === 'public') {
return 'primary';
}
if (visibility === 'friends') {
return 'success';
}
return 'info';
}
function changeWorldGroupVisibility(name, visibility) {
const params = {
type: 'world',
group: name,
visibility
};
favoriteRequest.saveFavoriteGroup(params).then((args) => {
handleFavoriteGroup({
json: args.json,
params: {
favoriteGroupId: args.json.id
}
});
ElMessage({
message: 'Group visibility changed',
type: 'success'
});
return args;
});
}
function promptNewLocalWorldFavoriteGroup() {
ElMessageBox.prompt(
t('prompt.new_local_favorite_group.description'),
t('prompt.new_local_favorite_group.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.new_local_favorite_group.ok'),
cancelButtonText: t('prompt.new_local_favorite_group.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.new_local_favorite_group.input_error')
}
)
.then(({ value }) => {
if (value) {
newLocalWorldFavoriteGroup(value);
}
})
.catch(() => {});
}
function promptLocalWorldFavoriteGroupRename(group) {
ElMessageBox.prompt(
t('prompt.local_favorite_group_rename.description'),
t('prompt.local_favorite_group_rename.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.local_favorite_group_rename.save'),
cancelButtonText: t('prompt.local_favorite_group_rename.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.local_favorite_group_rename.input_error'),
inputValue: group
}
)
.then(({ value }) => {
if (value) {
renameLocalWorldFavoriteGroup(value, group);
}
})
.catch(() => {});
}
function promptLocalWorldFavoriteGroupDelete(group) {
ElMessageBox.confirm(`Delete Group? ${group}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info'
})
.then((action) => {
if (action === 'confirm') {
deleteLocalWorldFavoriteGroup(group);
}
})
.catch(() => {});
}
function clearFavoriteGroup(ctx) {
ElMessageBox.confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info'
})
.then((action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
})
.catch(() => {});
}
function searchWorldFavorites(worldFavoriteSearch) {
let ref = null;
const search = worldFavoriteSearch.toLowerCase();
if (search.length < 3) {
worldFavoriteSearchResults.value = [];
return;
}
const results = [];
for (let i = 0; i < localWorldFavoriteGroups.length; ++i) {
const group = localWorldFavoriteGroups[i];
if (!localWorldFavorites.value[group]) {
continue;
}
for (let j = 0; j < localWorldFavorites.value[group].length; ++j) {
ref = localWorldFavorites.value[group][j];
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
}
for (let i = 0; i < favoriteWorlds.value.length; ++i) {
ref = favoriteWorlds.value[i].ref;
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
worldFavoriteSearchResults.value = results;
}
function changeFavoriteGroupName(group) {
emit('change-favorite-group-name', group);
}
async function refreshLocalWorldFavorites() {
if (refreshingLocalFavorites.value) {
return;
}
refreshingLocalFavorites.value = true;
const token = {
cancelled: false,
resolve: null
};
refreshCancelToken.value = token;
try {
for (const worldId of localWorldFavoritesList) {
if (token.cancelled) {
break;
}
try {
await worldRequest.getWorld({
worldId
});
} catch (err) {
console.error(err);
}
if (token.cancelled) {
break;
}
await new Promise((resolve) => {
token.resolve = resolve;
worker.value = workerTimers.setTimeout(() => {
worker.value = null;
resolve();
}, 1000);
});
}
} finally {
if (worker.value) {
workerTimers.clearTimeout(worker.value);
worker.value = null;
}
if (refreshCancelToken.value === token) {
refreshCancelToken.value = null;
}
refreshingLocalFavorites.value = false;
}
}
function cancelLocalWorldRefresh() {
if (!refreshingLocalFavorites.value) {
return;
}
if (refreshCancelToken.value) {
refreshCancelToken.value.cancelled = true;
if (typeof refreshCancelToken.value.resolve === 'function') {
refreshCancelToken.value.resolve();
}
}
if (worker.value) {
workerTimers.clearTimeout(worker.value);
worker.value = null;
}
refreshingLocalFavorites.value = false;
}
onBeforeUnmount(() => {
cancelLocalWorldRefresh();
});
</script>