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

@@ -47,11 +47,11 @@
:key="entry.label"
type="button"
class="nav-menu-popover__menu-item"
@click="handleSubmenuClick(entry.path, item.index)">
@click="handleSubmenuClick(entry, item.index)">
<span class="nav-menu-popover__menu-label"
>{{ t(entry.label)
}}<span
v-if="notifiedMenus.includes(entry.path.split('/').pop())"
v-if="notifiedMenus.includes(entry.routeName || entry.path.split('/').pop())"
class="nav-menu-popover__menu-label-dot"></span
></span>
</button>
@@ -250,7 +250,25 @@
{
index: 'favorites',
icon: 'ri-star-line',
tooltip: 'nav_tooltip.favorites'
tooltip: '',
title: 'nav_tooltip.favorites',
entries: [
{
label: 'view.favorite.friends.header',
path: '/favorites/friends',
routeName: 'favorite-friends'
},
{
label: 'view.favorite.worlds.header',
path: '/favorites/worlds',
routeName: 'favorite-worlds'
},
{
label: 'view.favorite.avatars.header',
path: '/favorites/avatars',
routeName: 'favorite-avatars'
}
]
},
{
index: 'social',
@@ -258,9 +276,21 @@
tooltip: '',
title: 'nav_tooltip.social',
entries: [
{ label: 'nav_tooltip.friend_log', path: '/social/friend-log' },
{ label: 'nav_tooltip.friend_list', path: '/social/friend-list' },
{ label: 'nav_tooltip.moderation', path: '/social/moderation' }
{
label: 'nav_tooltip.friend_log',
path: '/social/friend-log',
routeName: 'friend-log'
},
{
label: 'nav_tooltip.friend_list',
path: '/social/friend-list',
routeName: 'friend-list'
},
{
label: 'nav_tooltip.moderation',
path: '/social/moderation',
routeName: 'moderation'
}
]
},
@@ -301,7 +331,7 @@
storeToRefs(VRCXUpdaterStore);
const { showVRCXUpdateDialog, updateProgressText, showChangeLogDialog } = VRCXUpdaterStore;
const uiStore = useUiStore();
const { notifiedMenus } = storeToRefs(uiStore);
const { notifiedMenus, lastVisitedSocialRoute, lastVisitedFavoritesRoute } = storeToRefs(uiStore);
const { directAccessPaste } = useSearchStore();
const { sentryErrorReporting } = storeToRefs(useAdvancedSettingsStore());
const { setSentryErrorReporting } = useAdvancedSettingsStore();
@@ -358,11 +388,16 @@
}
};
const handleSubmenuClick = (path, index) => {
if (path) {
router.push(path);
navMenuRef.value?.updateActiveIndex(index);
const handleSubmenuClick = (entry, index) => {
if (!entry) {
return;
}
if (entry.routeName) {
router.push({ name: entry.routeName });
} else if (entry.path) {
router.push(entry.path);
}
navMenuRef.value?.updateActiveIndex(index);
};
const handleSubMenuBeforeEnter = () => {
@@ -372,10 +407,13 @@
};
const handleRouteChange = (index) => {
let targetName = index;
if (index === 'social') {
index = 'friend-log';
targetName = lastVisitedSocialRoute.value || 'friend-log';
} else if (index === 'favorites') {
targetName = lastVisitedFavoritesRoute.value || 'favorite-friends';
}
router.push({ name: index });
router.push({ name: targetName });
navMenuRef.value?.updateActiveIndex(index);
};

View File

@@ -1,7 +1,9 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import Charts from './../views/Charts/Charts.vue';
import Favorites from './../views/Favorites/Favorites.vue';
import FavoritesAvatar from './../views/Favorites/FavoritesAvatar.vue';
import FavoritesFriend from './../views/Favorites/FavoritesFriend.vue';
import FavoritesWorld from './../views/Favorites/FavoritesWorld.vue';
import Feed from './../views/Feed/Feed.vue';
import FriendList from './../views/FriendList/FriendList.vue';
import FriendLocation from './../views/FriendLocation/FriendLocation.vue';
@@ -24,7 +26,21 @@ const routes = [
{ path: '/game-log', name: 'game-log', component: GameLog },
{ path: '/player-list', name: 'player-list', component: PlayerList },
{ path: '/search', name: 'search', component: Search },
{ path: '/favorites', name: 'favorites', component: Favorites },
{
path: '/favorites/friends',
name: 'favorite-friends',
component: FavoritesFriend
},
{
path: '/favorites/worlds',
name: 'favorite-worlds',
component: FavoritesWorld
},
{
path: '/favorites/avatars',
name: 'favorite-avatars',
component: FavoritesAvatar
},
{ path: '/social/friend-log', name: 'friend-log', component: FriendLog },
{ path: '/social/moderation', name: 'moderation', component: Moderation },
{ path: '/notification', name: 'notification', component: Notification },

View File

@@ -56,4 +56,16 @@ function moveArrayItem(array, fromIndex, toIndex) {
array.splice(toIndex, 0, item);
}
export { removeFromArray, arraysMatch, moveArrayItem };
function replaceReactiveObject(target, source) {
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
delete target[key];
}
}
for (const key in source) {
target[key] = source[key];
}
}
export { removeFromArray, arraysMatch, moveArrayItem, replaceReactiveObject };

View File

@@ -3,7 +3,11 @@ import { ElMessage } from 'element-plus';
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { compareByName, removeFromArray } from '../shared/utils';
import {
compareByName,
removeFromArray,
replaceReactiveObject
} from '../shared/utils';
import { database } from '../service/database';
import { favoriteRequest } from '../api';
import { processBulk } from '../service/request';
@@ -34,8 +38,6 @@ export const useFavoriteStore = defineStore('Favorite', () => {
const cachedFavorites = reactive(new Map());
const currentFavoriteTab = ref('friend');
const cachedFavoriteGroups = ref({});
const isFavoriteGroupLoading = ref(false);
@@ -72,11 +74,13 @@ export const useFavoriteStore = defineStore('Favorite', () => {
const friendImportDialogVisible = ref(false);
const localWorldFavorites = ref({});
const localWorldFavorites = reactive({});
const localAvatarFavorites = ref({});
const localAvatarFavorites = reactive({});
const editFavoritesMode = ref(false);
const selectedFavoriteFriends = ref([]);
const selectedFavoriteWorlds = ref([]);
const selectedFavoriteAvatars = ref([]);
const favoriteDialog = ref({
visible: false,
@@ -113,22 +117,46 @@ export const useFavoriteStore = defineStore('Favorite', () => {
return sorted;
});
watch(
favoriteFriends,
(list) => {
syncFavoriteSelection(list, selectedFavoriteFriends);
},
{ immediate: true }
);
watch(
favoriteWorlds,
(list) => {
syncFavoriteSelection(list, selectedFavoriteWorlds);
},
{ immediate: true }
);
watch(
favoriteAvatars,
(list) => {
syncFavoriteSelection(list, selectedFavoriteAvatars);
},
{ immediate: true }
);
const localAvatarFavoriteGroups = computed(() =>
Object.keys(localAvatarFavorites.value).sort()
Object.keys(localAvatarFavorites).sort()
);
const localWorldFavoriteGroups = computed(() =>
Object.keys(localWorldFavorites.value).sort()
Object.keys(localWorldFavorites).sort()
);
const localWorldFavoritesList = computed(() =>
Object.values(localWorldFavorites.value)
Object.values(localWorldFavorites)
.flat()
.map((fav) => fav.id)
);
const localAvatarFavoritesList = computed(() =>
Object.values(localAvatarFavorites.value)
Object.values(localAvatarFavorites)
.flat()
.map((fav) => fav.id)
);
@@ -147,7 +175,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
const localWorldFavGroupLength = computed(() => (group) => {
const favoriteGroup = localWorldFavorites.value[group];
const favoriteGroup = localWorldFavorites[group];
if (!favoriteGroup) {
return 0;
}
@@ -155,13 +183,27 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
const localAvatarFavGroupLength = computed(() => (group) => {
const favoriteGroup = localAvatarFavorites.value[group];
const favoriteGroup = localAvatarFavorites[group];
if (!favoriteGroup) {
return 0;
}
return favoriteGroup.length;
});
function syncFavoriteSelection(list, selectionRef) {
if (!Array.isArray(list)) {
selectionRef.value = [];
return;
}
const availableIds = new Set(list.map((item) => item.id));
const filtered = selectionRef.value.filter((id) =>
availableIds.has(id)
);
if (filtered.length !== selectionRef.value.length) {
selectionRef.value = filtered;
}
}
watch(
() => watchState.isLoggedIn,
(isLoggedIn) => {
@@ -177,7 +219,11 @@ export const useFavoriteStore = defineStore('Favorite', () => {
state.favoriteFriends_ = [];
state.favoriteWorlds_ = [];
state.favoriteAvatars_ = [];
localAvatarFavorites.value = {};
replaceReactiveObject(localWorldFavorites, {});
replaceReactiveObject(localAvatarFavorites, {});
selectedFavoriteFriends.value = [];
selectedFavoriteWorlds.value = [];
selectedFavoriteAvatars.value = [];
favoriteDialog.value.visible = false;
worldImportDialogVisible.value = false;
avatarImportDialogVisible.value = false;
@@ -353,8 +399,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
type,
groupKey: favorite.$groupKey,
ref: null,
name: '',
$selected: false
name: ''
};
if (type === 'friend') {
ref = userStore.cachedUsers.get(objectId);
@@ -807,19 +852,6 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
}
function clearBulkFavoriteSelection() {
let ctx;
for (ctx of state.favoriteFriends_) {
ctx.$selected = false;
}
for (ctx of state.favoriteWorlds_) {
ctx.$selected = false;
}
for (ctx of state.favoriteAvatars_) {
ctx.$selected = false;
}
}
function showWorldImportDialog() {
worldImportDialogVisible.value = true;
}
@@ -845,11 +877,11 @@ export const useFavoriteStore = defineStore('Favorite', () => {
if (typeof ref === 'undefined') {
return;
}
if (!localWorldFavorites.value[group]) {
localWorldFavorites.value[group] = [];
if (!localWorldFavorites[group]) {
localWorldFavorites[group] = [];
}
localWorldFavorites.value[group].unshift(ref);
localWorldFavorites[group].unshift(ref);
database.addWorldToCache(ref);
database.addWorldToFavorites(worldId, group);
if (
@@ -876,7 +908,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
* @returns {boolean}
*/
function hasLocalWorldFavorite(worldId, group) {
const favoriteGroup = localWorldFavorites.value[group];
const favoriteGroup = localWorldFavorites[group];
if (!favoriteGroup) {
return false;
}
@@ -901,10 +933,10 @@ export const useFavoriteStore = defineStore('Favorite', () => {
if (typeof ref === 'undefined') {
return;
}
if (!localAvatarFavorites.value[group]) {
localAvatarFavorites.value[group] = [];
if (!localAvatarFavorites[group]) {
localAvatarFavorites[group] = [];
}
localAvatarFavorites.value[group].unshift(ref);
localAvatarFavorites[group].unshift(ref);
database.addAvatarToCache(ref);
database.addAvatarToFavorites(avatarId, group);
if (
@@ -931,7 +963,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
* @returns {boolean}
*/
function hasLocalAvatarFavorite(avatarId, group) {
const favoriteGroup = localAvatarFavorites.value[group];
const favoriteGroup = localAvatarFavorites[group];
if (!favoriteGroup) {
return false;
}
@@ -981,25 +1013,21 @@ export const useFavoriteStore = defineStore('Favorite', () => {
let i;
// remove from cache if no longer in favorites
const avatarIdRemoveList = new Set();
const favoriteGroup = localAvatarFavorites.value[group];
const favoriteGroup = localAvatarFavorites[group];
for (i = 0; i < favoriteGroup.length; ++i) {
avatarIdRemoveList.add(favoriteGroup[i].id);
}
delete localAvatarFavorites.value[group];
delete localAvatarFavorites[group];
database.deleteAvatarFavoriteGroup(group);
for (i = 0; i < localAvatarFavoriteGroups.value.length; ++i) {
const groupName = localAvatarFavoriteGroups.value[i];
if (!localAvatarFavorites.value[groupName]) {
if (!localAvatarFavorites[groupName]) {
continue;
}
for (
let j = 0;
j < localAvatarFavorites.value[groupName].length;
++j
) {
const avatarId = localAvatarFavorites.value[groupName][j].id;
for (let j = 0; j < localAvatarFavorites[groupName].length; ++j) {
const avatarId = localAvatarFavorites[groupName][j].id;
if (avatarIdRemoveList.has(avatarId)) {
avatarIdRemoveList.delete(avatarId);
break;
@@ -1016,19 +1044,15 @@ export const useFavoriteStore = defineStore('Favorite', () => {
++i
) {
const groupName = localAvatarFavoriteGroups.value[i];
if (
!localAvatarFavorites.value[groupName] ||
group === groupName
) {
if (!localAvatarFavorites[groupName] || group === groupName) {
continue loop;
}
for (
let j = 0;
j < localAvatarFavorites.value[groupName].length;
j < localAvatarFavorites[groupName].length;
++j
) {
const avatarId =
localAvatarFavorites.value[groupName][j].id;
const avatarId = localAvatarFavorites[groupName][j].id;
if (id === avatarId) {
avatarInFavorites = true;
break loop;
@@ -1047,8 +1071,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
if (!appearanceSettingsStore.sortFavorites) {
for (let i = 0; i < localAvatarFavoriteGroups.value.length; ++i) {
const group = localAvatarFavoriteGroups.value[i];
if (localAvatarFavorites.value[group]) {
localAvatarFavorites.value[group].sort(compareByName);
if (localAvatarFavorites[group]) {
localAvatarFavorites[group].sort(compareByName);
}
}
}
@@ -1069,9 +1093,9 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
return;
}
localAvatarFavorites.value[newName] = localAvatarFavorites.value[group];
localAvatarFavorites[newName] = localAvatarFavorites[group];
delete localAvatarFavorites.value[group];
delete localAvatarFavorites[group];
database.renameAvatarFavoriteGroup(newName, group);
sortLocalAvatarFavorites();
}
@@ -1090,8 +1114,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
return;
}
if (!localAvatarFavorites.value[group]) {
localAvatarFavorites.value[group] = [];
if (!localAvatarFavorites[group]) {
localAvatarFavorites[group] = [];
}
sortLocalAvatarFavorites();
}
@@ -1138,7 +1162,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
groupsArr = ['Favorites'];
}
localAvatarFavorites.value = localFavorites;
replaceReactiveObject(localAvatarFavorites, localFavorites);
sortLocalAvatarFavorites();
}
@@ -1150,7 +1174,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
*/
function removeLocalAvatarFavorite(avatarId, group) {
let i;
const favoriteGroup = localAvatarFavorites.value[group];
const favoriteGroup = localAvatarFavorites[group];
for (i = 0; i < favoriteGroup.length; ++i) {
if (favoriteGroup[i].id === avatarId) {
favoriteGroup.splice(i, 1);
@@ -1161,15 +1185,11 @@ export const useFavoriteStore = defineStore('Favorite', () => {
let avatarInFavorites = false;
for (i = 0; i < localAvatarFavoriteGroups.value.length; ++i) {
const groupName = localAvatarFavoriteGroups.value[i];
if (!localAvatarFavorites.value[groupName] || group === groupName) {
if (!localAvatarFavorites[groupName] || group === groupName) {
continue;
}
for (
let j = 0;
j < localAvatarFavorites.value[groupName].length;
++j
) {
const id = localAvatarFavorites.value[groupName][j].id;
for (let j = 0; j < localAvatarFavorites[groupName].length; ++j) {
const id = localAvatarFavorites[groupName][j].id;
if (id === avatarId) {
avatarInFavorites = true;
break;
@@ -1208,25 +1228,21 @@ export const useFavoriteStore = defineStore('Favorite', () => {
let i;
// remove from cache if no longer in favorites
const worldIdRemoveList = new Set();
const favoriteGroup = localWorldFavorites.value[group];
const favoriteGroup = localWorldFavorites[group];
for (i = 0; i < favoriteGroup.length; ++i) {
worldIdRemoveList.add(favoriteGroup[i].id);
}
delete localWorldFavorites.value[group];
delete localWorldFavorites[group];
database.deleteWorldFavoriteGroup(group);
for (i = 0; i < localWorldFavoriteGroups.value.length; ++i) {
const groupName = localWorldFavoriteGroups.value[i];
if (!localWorldFavorites.value[groupName]) {
if (!localWorldFavorites[groupName]) {
continue;
}
for (
let j = 0;
j < localWorldFavorites.value[groupName].length;
++j
) {
const worldId = localWorldFavorites.value[groupName][j].id;
for (let j = 0; j < localWorldFavorites[groupName].length; ++j) {
const worldId = localWorldFavorites[groupName][j].id;
if (worldIdRemoveList.has(worldId)) {
worldIdRemoveList.delete(worldId);
break;
@@ -1243,8 +1259,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
if (!appearanceSettingsStore.sortFavorites) {
for (let i = 0; i < localWorldFavoriteGroups.value.length; ++i) {
const group = localWorldFavoriteGroups.value[i];
if (localWorldFavorites.value[group]) {
localWorldFavorites.value[group].sort(compareByName);
if (localWorldFavorites[group]) {
localWorldFavorites[group].sort(compareByName);
}
}
}
@@ -1265,9 +1281,9 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
return;
}
localWorldFavorites.value[newName] = localWorldFavorites.value[group];
localWorldFavorites[newName] = localWorldFavorites[group];
delete localWorldFavorites.value[group];
delete localWorldFavorites[group];
database.renameWorldFavoriteGroup(newName, group);
sortLocalWorldFavorites();
}
@@ -1279,7 +1295,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
*/
function removeLocalWorldFavorite(worldId, group) {
let i;
const favoriteGroup = localWorldFavorites.value[group];
const favoriteGroup = localWorldFavorites[group];
for (i = 0; i < favoriteGroup.length; ++i) {
if (favoriteGroup[i].id === worldId) {
favoriteGroup.splice(i, 1);
@@ -1290,15 +1306,11 @@ export const useFavoriteStore = defineStore('Favorite', () => {
let worldInFavorites = false;
for (i = 0; i < localWorldFavoriteGroups.value.length; ++i) {
const groupName = localWorldFavoriteGroups.value[i];
if (!localWorldFavorites.value[groupName] || group === groupName) {
if (!localWorldFavorites[groupName] || group === groupName) {
continue;
}
for (
let j = 0;
j < localWorldFavorites.value[groupName].length;
++j
) {
const id = localWorldFavorites.value[groupName][j].id;
for (let j = 0; j < localWorldFavorites[groupName].length; ++j) {
const id = localWorldFavorites[groupName][j].id;
if (id === worldId) {
worldInFavorites = true;
break;
@@ -1369,7 +1381,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
groupsArr = ['Favorites'];
}
localWorldFavorites.value = localFavorites;
replaceReactiveObject(localWorldFavorites, localFavorites);
sortLocalWorldFavorites();
}
@@ -1388,11 +1400,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
return;
}
if (!localWorldFavorites.value[group]) {
localWorldFavorites.value[group] = [];
}
if (!localWorldFavoriteGroups.value.includes(group)) {
localWorldFavoriteGroups.value.push(group);
if (!localWorldFavorites[group]) {
localWorldFavorites[group] = [];
}
sortLocalWorldFavorites();
}
@@ -1466,10 +1475,11 @@ export const useFavoriteStore = defineStore('Favorite', () => {
localWorldFavoriteGroups,
groupedByGroupKeyFavoriteFriends,
currentFavoriteTab,
selectedFavoriteFriends,
selectedFavoriteWorlds,
selectedFavoriteAvatars,
localWorldFavGroupLength,
localAvatarFavGroupLength,
editFavoritesMode,
initFavorites,
applyFavorite,
@@ -1477,7 +1487,6 @@ export const useFavoriteStore = defineStore('Favorite', () => {
refreshFavorites,
applyFavoriteGroup,
refreshFavoriteAvatars,
clearBulkFavoriteSelection,
showWorldImportDialog,
showAvatarImportDialog,
showFriendImportDialog,

View File

@@ -24,6 +24,14 @@ export const useUiStore = defineStore('Ui', () => {
const notifiedMenus = ref([]);
const shiftHeld = ref(false);
const trayIconNotify = ref(false);
const socialRouteNames = ['friend-log', 'friend-list', 'moderation'];
const favoriteRouteNames = [
'favorite-friends',
'favorite-worlds',
'favorite-avatars'
];
const lastVisitedSocialRoute = ref(socialRouteNames[0]);
const lastVisitedFavoritesRoute = ref(favoriteRouteNames[0]);
watch(
() => watchState.isLoggedIn,
@@ -50,10 +58,16 @@ export const useUiStore = defineStore('Ui', () => {
() => router.currentRoute.value.name,
(routeName) => {
if (routeName) {
removeNotify(routeName);
if (routeName === 'notification') {
const name = String(routeName);
removeNotify(name);
if (name === 'notification') {
notificationStore.unseenNotifications = [];
}
if (socialRouteNames.includes(name)) {
lastVisitedSocialRoute.value = name;
} else if (favoriteRouteNames.includes(name)) {
lastVisitedFavoritesRoute.value = name;
}
}
}
);
@@ -82,6 +96,8 @@ export const useUiStore = defineStore('Ui', () => {
return {
notifiedMenus,
shiftHeld,
lastVisitedSocialRoute,
lastVisitedFavoritesRoute,
notifyMenu,
removeNotify

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>