mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-28 19:23:47 +02:00
feat: add scaling options to favorites grid (#1477)
This commit is contained in:
@@ -17,14 +17,27 @@
|
|||||||
class="favorites-toolbar__search"
|
class="favorites-toolbar__search"
|
||||||
:placeholder="t('view.favorite.avatars.search')"
|
:placeholder="t('view.favorite.avatars.search')"
|
||||||
@input="searchAvatarFavorites" />
|
@input="searchAvatarFavorites" />
|
||||||
<el-dropdown trigger="click" :hide-on-click="true">
|
<el-dropdown ref="avatarToolbarMenuRef" trigger="click" :hide-on-click="false">
|
||||||
<el-button :icon="MoreFilled" size="small" circle />
|
<el-button :icon="MoreFilled" size="small" circle />
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu class="favorites-dropdown">
|
||||||
<el-dropdown-item @click="showAvatarImportDialog">
|
<li class="favorites-dropdown__scale" @click.stop>
|
||||||
|
<div class="favorites-dropdown__scale-header">
|
||||||
|
<span>Scale</span>
|
||||||
|
<span class="favorites-dropdown__scale-value">{{ avatarCardScalePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<el-slider
|
||||||
|
v-model="avatarCardScale"
|
||||||
|
class="favorites-dropdown__slider"
|
||||||
|
:min="avatarCardScaleSlider.min"
|
||||||
|
:max="avatarCardScaleSlider.max"
|
||||||
|
:step="avatarCardScaleSlider.step"
|
||||||
|
:show-tooltip="false" />
|
||||||
|
</li>
|
||||||
|
<el-dropdown-item @click="handleAvatarImportClick">
|
||||||
{{ t('view.favorite.import') }}
|
{{ t('view.favorite.import') }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item divided @click="showAvatarExportDialog">
|
<el-dropdown-item divided @click="handleAvatarExportClick">
|
||||||
{{ t('view.favorite.export') }}
|
{{ t('view.favorite.export') }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
@@ -32,8 +45,8 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-splitter class="favorites-splitter">
|
<el-splitter class="favorites-splitter" @resize-end="handleAvatarSplitterResize">
|
||||||
<el-splitter-panel :size="260" :min="0" :max="360" collapsible>
|
<el-splitter-panel :size="avatarSplitterSize" :min="0" :max="360" collapsible>
|
||||||
<div class="favorites-groups-panel">
|
<div class="favorites-groups-panel">
|
||||||
<div class="group-section">
|
<div class="group-section">
|
||||||
<div class="group-section__header">
|
<div class="group-section__header">
|
||||||
@@ -94,7 +107,6 @@
|
|||||||
<span>{{ t('view.favorite.rename_tooltip') }}</span>
|
<span>{{ t('view.favorite.rename_tooltip') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<el-popover
|
<el-popover
|
||||||
popper-class="favorites-group-menu__popover"
|
|
||||||
placement="right"
|
placement="right"
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
:width="180"
|
:width="180"
|
||||||
@@ -344,10 +356,13 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="favorites-content__list">
|
<div ref="avatarFavoritesContainerRef" class="favorites-content__list">
|
||||||
<template v-if="isSearchActive">
|
<template v-if="isSearchActive">
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<div v-if="avatarFavoriteSearchResults.length" class="favorites-search-grid">
|
<div
|
||||||
|
v-if="avatarFavoriteSearchResults.length"
|
||||||
|
class="favorites-search-grid"
|
||||||
|
:style="avatarFavoritesGridStyle(avatarFavoriteSearchResults.length)">
|
||||||
<div
|
<div
|
||||||
v-for="favorite in avatarFavoriteSearchResults"
|
v-for="favorite in avatarFavoriteSearchResults"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -377,7 +392,9 @@
|
|||||||
<template v-else-if="activeRemoteGroup">
|
<template v-else-if="activeRemoteGroup">
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<template v-if="currentRemoteFavorites.length">
|
<template v-if="currentRemoteFavorites.length">
|
||||||
<div class="favorites-card-list">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="avatarFavoritesGridStyle(currentRemoteFavorites.length)">
|
||||||
<FavoritesAvatarItem
|
<FavoritesAvatarItem
|
||||||
v-for="favorite in currentRemoteFavorites"
|
v-for="favorite in currentRemoteFavorites"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -394,7 +411,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="!remoteAvatarGroupsResolved">
|
<template v-else-if="!remoteAvatarGroupsResolved">
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<div class="favorites-card-list favorites-card-list--placeholder">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="avatarFavoritesGridStyle(avatarGroupPlaceholders.length)">
|
||||||
<div
|
<div
|
||||||
v-for="group in avatarGroupPlaceholders"
|
v-for="group in avatarGroupPlaceholders"
|
||||||
:key="group.key"
|
:key="group.key"
|
||||||
@@ -402,23 +421,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="!remoteAvatarGroupsResolved">
|
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
|
||||||
<div class="favorites-card-list">
|
|
||||||
<div
|
|
||||||
v-for="group in avatarGroupPlaceholders"
|
|
||||||
:key="group.key"
|
|
||||||
class="favorites-card-list__placeholder"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="activeLocalGroupName">
|
<template v-else-if="activeLocalGroupName">
|
||||||
<el-scrollbar
|
<el-scrollbar
|
||||||
ref="localAvatarScrollbarRef"
|
ref="localAvatarScrollbarRef"
|
||||||
class="favorites-content__scroll"
|
class="favorites-content__scroll"
|
||||||
@scroll="handleLocalAvatarScroll">
|
@scroll="handleLocalAvatarScroll">
|
||||||
<template v-if="currentLocalFavorites.length">
|
<template v-if="currentLocalFavorites.length">
|
||||||
<div class="favorites-card-list">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="avatarFavoritesGridStyle(currentLocalFavorites.length)">
|
||||||
<FavoritesAvatarItem
|
<FavoritesAvatarItem
|
||||||
v-for="favorite in currentLocalFavorites"
|
v-for="favorite in currentLocalFavorites"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -435,7 +446,9 @@
|
|||||||
<template v-else-if="isHistorySelected">
|
<template v-else-if="isHistorySelected">
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<template v-if="avatarHistory.length">
|
<template v-if="avatarHistory.length">
|
||||||
<div class="favorites-card-list">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="avatarFavoritesGridStyle(avatarHistory.length)">
|
||||||
<FavoritesAvatarLocalHistoryItem
|
<FavoritesAvatarLocalHistoryItem
|
||||||
v-for="favorite in avatarHistory"
|
v-for="favorite in avatarHistory"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -458,7 +471,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { Loading, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
import { Loading, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -466,10 +479,12 @@
|
|||||||
|
|
||||||
import { useAppearanceSettingsStore, useAvatarStore, useFavoriteStore, useUserStore } from '../../stores';
|
import { useAppearanceSettingsStore, useAvatarStore, useFavoriteStore, useUserStore } from '../../stores';
|
||||||
import { avatarRequest, favoriteRequest } from '../../api';
|
import { avatarRequest, favoriteRequest } from '../../api';
|
||||||
|
import { useFavoritesCardScaling } from './composables/useFavoritesCardScaling.js';
|
||||||
|
|
||||||
import AvatarExportDialog from './dialogs/AvatarExportDialog.vue';
|
import AvatarExportDialog from './dialogs/AvatarExportDialog.vue';
|
||||||
import FavoritesAvatarItem from './components/FavoritesAvatarItem.vue';
|
import FavoritesAvatarItem from './components/FavoritesAvatarItem.vue';
|
||||||
import FavoritesAvatarLocalHistoryItem from './components/FavoritesAvatarLocalHistoryItem.vue';
|
import FavoritesAvatarLocalHistoryItem from './components/FavoritesAvatarLocalHistoryItem.vue';
|
||||||
|
import configRepository from '../../service/config.js';
|
||||||
|
|
||||||
import * as workerTimers from 'worker-timers';
|
import * as workerTimers from 'worker-timers';
|
||||||
|
|
||||||
@@ -484,6 +499,7 @@
|
|||||||
|
|
||||||
const avatarGroupVisibilityOptions = ref(['public', 'friends', 'private']);
|
const avatarGroupVisibilityOptions = ref(['public', 'friends', 'private']);
|
||||||
const historyGroupKey = 'local-history';
|
const historyGroupKey = 'local-history';
|
||||||
|
const avatarSplitterSize = ref(260);
|
||||||
|
|
||||||
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
|
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
|
||||||
const { setSortFavorites } = useAppearanceSettingsStore();
|
const { setSortFavorites } = useAppearanceSettingsStore();
|
||||||
@@ -513,12 +529,27 @@
|
|||||||
const { isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
|
const { isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
cardScale: avatarCardScale,
|
||||||
|
slider: avatarCardScaleSlider,
|
||||||
|
containerRef: avatarFavoritesContainerRef,
|
||||||
|
gridStyle: avatarFavoritesGridStyle
|
||||||
|
} = useFavoritesCardScaling({
|
||||||
|
configKey: 'VRCX_FavoritesAvatarCardScale',
|
||||||
|
min: 0.6,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarCardScalePercent = computed(() => Math.round(avatarCardScale.value * 100));
|
||||||
|
|
||||||
const avatarExportDialogVisible = ref(false);
|
const avatarExportDialogVisible = ref(false);
|
||||||
const avatarFavoriteSearch = ref('');
|
const avatarFavoriteSearch = ref('');
|
||||||
const avatarFavoriteSearchResults = ref([]);
|
const avatarFavoriteSearchResults = ref([]);
|
||||||
const avatarEditMode = ref(false);
|
const avatarEditMode = ref(false);
|
||||||
const selectedGroup = ref(null);
|
const selectedGroup = ref(null);
|
||||||
const activeGroupMenu = ref(null);
|
const activeGroupMenu = ref(null);
|
||||||
|
const avatarToolbarMenuRef = ref();
|
||||||
const isCreatingLocalGroup = ref(false);
|
const isCreatingLocalGroup = ref(false);
|
||||||
const newLocalGroupName = ref('');
|
const newLocalGroupName = ref('');
|
||||||
const newLocalGroupInput = ref(null);
|
const newLocalGroupInput = ref(null);
|
||||||
@@ -552,6 +583,43 @@
|
|||||||
const localGroupMenuKey = (key) => `local:${key}`;
|
const localGroupMenuKey = (key) => `local:${key}`;
|
||||||
const historyGroupMenuKey = 'history';
|
const historyGroupMenuKey = 'history';
|
||||||
|
|
||||||
|
const closeAvatarToolbarMenu = () => {
|
||||||
|
avatarToolbarMenuRef.value?.handleClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleAvatarImportClick() {
|
||||||
|
closeAvatarToolbarMenu();
|
||||||
|
showAvatarImportDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarExportClick() {
|
||||||
|
closeAvatarToolbarMenu();
|
||||||
|
showAvatarExportDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
loadAvatarSplitterPreferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAvatarSplitterPreferences() {
|
||||||
|
const storedSize = await configRepository.getString('VRCX_FavoritesAvatarSplitter', '260');
|
||||||
|
if (typeof storedSize === 'string' && !Number.isNaN(Number(storedSize)) && Number(storedSize) > 0) {
|
||||||
|
avatarSplitterSize.value = Number(storedSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarSplitterResize(panelIndex, sizes) {
|
||||||
|
if (!Array.isArray(sizes) || !sizes.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSize = sizes[0];
|
||||||
|
if (nextSize <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
avatarSplitterSize.value = nextSize;
|
||||||
|
configRepository.setString('VRCX_FavoritesAvatarSplitter', nextSize.toString());
|
||||||
|
}
|
||||||
|
|
||||||
const groupedAvatarFavorites = computed(() => {
|
const groupedAvatarFavorites = computed(() => {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
favoriteAvatars.value.forEach((avatar) => {
|
favoriteAvatars.value.forEach((avatar) => {
|
||||||
@@ -1264,6 +1332,10 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.group-section {
|
.group-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1518,15 +1590,23 @@
|
|||||||
|
|
||||||
.favorites-search-grid {
|
.favorites-search-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 12px;
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 240px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorites-card-list {
|
.favorites-card-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 12px;
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 260px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
padding: 4px 2px 12px 2px;
|
padding: 4px 2px 12px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,22 +1615,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card--avatar) {
|
:deep(.favorites-search-card--avatar) {
|
||||||
flex: 0 1 280px;
|
min-width: var(--favorites-card-min-width, 240px);
|
||||||
min-width: 240px;
|
max-width: var(--favorites-card-target-width, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card) {
|
:deep(.favorites-search-card) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: 8px;
|
border-radius: calc(8px * var(--favorites-card-scale, 1));
|
||||||
padding: 8px 10px;
|
padding: calc(8px * var(--favorites-card-scale, 1)) calc(10px * var(--favorites-card-scale, 1));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
box-shadow 0.2s ease;
|
box-shadow 0.2s ease;
|
||||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
||||||
|
width: 100%;
|
||||||
|
min-width: var(--favorites-card-min-width, 240px);
|
||||||
|
max-width: var(--favorites-card-target-width, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card:hover) {
|
:deep(.favorites-search-card:hover) {
|
||||||
@@ -1566,15 +1650,15 @@
|
|||||||
:deep(.favorites-search-card__content) {
|
:deep(.favorites-search-card__content) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: calc(10px * var(--favorites-card-scale, 1));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card__avatar) {
|
:deep(.favorites-search-card__avatar) {
|
||||||
width: 48px;
|
width: calc(48px * var(--favorites-card-scale, 1));
|
||||||
height: 48px;
|
height: calc(48px * var(--favorites-card-scale, 1));
|
||||||
border-radius: 6px;
|
border-radius: calc(6px * var(--favorites-card-scale, 1));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--el-fill-color-lighter);
|
background: var(--el-fill-color-lighter);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1600,7 +1684,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 13px;
|
font-size: calc(13px * var(--favorites-card-scale, 1));
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1612,7 +1696,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card__detail .extra) {
|
:deep(.favorites-search-card__detail .extra) {
|
||||||
font-size: 12px;
|
font-size: calc(12px * var(--favorites-card-scale, 1));
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1686,4 +1770,35 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale {
|
||||||
|
list-style: none;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
min-width: 220px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__slider {
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__slider :deep(.el-slider__runway) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,14 +17,27 @@
|
|||||||
class="favorites-toolbar__search"
|
class="favorites-toolbar__search"
|
||||||
:placeholder="t('view.favorite.worlds.search')"
|
:placeholder="t('view.favorite.worlds.search')"
|
||||||
@input="searchFriendFavorites" />
|
@input="searchFriendFavorites" />
|
||||||
<el-dropdown trigger="click" :hide-on-click="true">
|
<el-dropdown ref="friendToolbarMenuRef" trigger="click" :hide-on-click="false">
|
||||||
<el-button :icon="MoreFilled" size="small" circle />
|
<el-button :icon="MoreFilled" size="small" circle @click.stop />
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu class="favorites-dropdown">
|
||||||
<el-dropdown-item @click="showFriendImportDialog">
|
<li class="favorites-dropdown__scale" @click.stop>
|
||||||
|
<div class="favorites-dropdown__scale-header">
|
||||||
|
<span>Scale</span>
|
||||||
|
<span class="favorites-dropdown__scale-value">{{ friendCardScalePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<el-slider
|
||||||
|
v-model="friendCardScale"
|
||||||
|
class="favorites-dropdown__slider"
|
||||||
|
:min="friendCardScaleSlider.min"
|
||||||
|
:max="friendCardScaleSlider.max"
|
||||||
|
:step="friendCardScaleSlider.step"
|
||||||
|
:show-tooltip="false" />
|
||||||
|
</li>
|
||||||
|
<el-dropdown-item @click="handleFriendImportClick">
|
||||||
{{ t('view.favorite.import') }}
|
{{ t('view.favorite.import') }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item divided @click="showFriendExportDialog">
|
<el-dropdown-item divided @click="handleFriendExportClick">
|
||||||
{{ t('view.favorite.export') }}
|
{{ t('view.favorite.export') }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
@@ -32,8 +45,8 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-splitter class="favorites-splitter">
|
<el-splitter class="favorites-splitter" @resize-end="handleFriendSplitterResize">
|
||||||
<el-splitter-panel :size="260" :min="0" :max="360" collapsible>
|
<el-splitter-panel :size="friendSplitterSize" :min="0" :max="360" collapsible>
|
||||||
<div class="favorites-groups-panel">
|
<div class="favorites-groups-panel">
|
||||||
<div class="group-section">
|
<div class="group-section">
|
||||||
<div class="group-section__header">
|
<div class="group-section__header">
|
||||||
@@ -94,7 +107,6 @@
|
|||||||
<span>{{ t('view.favorite.rename_tooltip') }}</span>
|
<span>{{ t('view.favorite.rename_tooltip') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<el-popover
|
<el-popover
|
||||||
popper-class="favorites-group-menu__popover"
|
|
||||||
placement="right"
|
placement="right"
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
:width="180"
|
:width="180"
|
||||||
@@ -179,11 +191,13 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="favorites-content__list">
|
<div ref="friendFavoritesContainerRef" class="favorites-content__list">
|
||||||
<template v-if="activeRemoteGroup && !isSearchActive">
|
<template v-if="activeRemoteGroup && !isSearchActive">
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<template v-if="currentFriendFavorites.length">
|
<template v-if="currentFriendFavorites.length">
|
||||||
<div class="favorites-card-list">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="friendFavoritesGridStyle(currentFriendFavorites.length)">
|
||||||
<FavoritesFriendItem
|
<FavoritesFriendItem
|
||||||
v-for="favorite in currentFriendFavorites"
|
v-for="favorite in currentFriendFavorites"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -203,7 +217,10 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<div v-if="friendFavoriteSearchResults.length" class="favorites-search-grid">
|
<div
|
||||||
|
v-if="friendFavoriteSearchResults.length"
|
||||||
|
class="favorites-search-grid"
|
||||||
|
:style="friendFavoritesGridStyle(friendFavoriteSearchResults.length)">
|
||||||
<div
|
<div
|
||||||
v-for="favorite in friendFavoriteSearchResults"
|
v-for="favorite in friendFavoriteSearchResults"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -242,7 +259,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, onBeforeMount, ref, watch } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { MoreFilled, Refresh } from '@element-plus/icons-vue';
|
import { MoreFilled, Refresh } from '@element-plus/icons-vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -250,13 +267,17 @@
|
|||||||
|
|
||||||
import { useAppearanceSettingsStore, useFavoriteStore, useUserStore } from '../../stores';
|
import { useAppearanceSettingsStore, useFavoriteStore, useUserStore } from '../../stores';
|
||||||
import { favoriteRequest } from '../../api';
|
import { favoriteRequest } from '../../api';
|
||||||
|
import { useFavoritesCardScaling } from './composables/useFavoritesCardScaling.js';
|
||||||
import { userImage } from '../../shared/utils';
|
import { userImage } from '../../shared/utils';
|
||||||
|
|
||||||
import FavoritesFriendItem from './components/FavoritesFriendItem.vue';
|
import FavoritesFriendItem from './components/FavoritesFriendItem.vue';
|
||||||
import FriendExportDialog from './dialogs/FriendExportDialog.vue';
|
import FriendExportDialog from './dialogs/FriendExportDialog.vue';
|
||||||
|
import configRepository from '../../service/config.js';
|
||||||
|
|
||||||
const friendGroupVisibilityOptions = ref(['public', 'friends', 'private']);
|
const friendGroupVisibilityOptions = ref(['public', 'friends', 'private']);
|
||||||
|
|
||||||
|
const friendSplitterSize = ref(260);
|
||||||
|
|
||||||
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
|
const { sortFavorites } = storeToRefs(useAppearanceSettingsStore());
|
||||||
const { setSortFavorites } = useAppearanceSettingsStore();
|
const { setSortFavorites } = useAppearanceSettingsStore();
|
||||||
const favoriteStore = useFavoriteStore();
|
const favoriteStore = useFavoriteStore();
|
||||||
@@ -272,12 +293,27 @@
|
|||||||
const { showUserDialog } = useUserStore();
|
const { showUserDialog } = useUserStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
cardScale: friendCardScale,
|
||||||
|
slider: friendCardScaleSlider,
|
||||||
|
containerRef: friendFavoritesContainerRef,
|
||||||
|
gridStyle: friendFavoritesGridStyle
|
||||||
|
} = useFavoritesCardScaling({
|
||||||
|
configKey: 'VRCX_FavoritesFriendCardScale',
|
||||||
|
min: 0.6,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01
|
||||||
|
});
|
||||||
|
|
||||||
|
const friendCardScalePercent = computed(() => Math.round(friendCardScale.value * 100));
|
||||||
|
|
||||||
const friendExportDialogVisible = ref(false);
|
const friendExportDialogVisible = ref(false);
|
||||||
const friendFavoriteSearch = ref('');
|
const friendFavoriteSearch = ref('');
|
||||||
const friendFavoriteSearchResults = ref([]);
|
const friendFavoriteSearchResults = ref([]);
|
||||||
const friendEditMode = ref(false);
|
const friendEditMode = ref(false);
|
||||||
const selectedGroup = ref(null);
|
const selectedGroup = ref(null);
|
||||||
const activeGroupMenu = ref(null);
|
const activeGroupMenu = ref(null);
|
||||||
|
const friendToolbarMenuRef = ref();
|
||||||
|
|
||||||
const sortFav = computed({
|
const sortFav = computed({
|
||||||
get() {
|
get() {
|
||||||
@@ -293,6 +329,43 @@
|
|||||||
const isSearchActive = computed(() => friendFavoriteSearch.value.trim().length >= 3);
|
const isSearchActive = computed(() => friendFavoriteSearch.value.trim().length >= 3);
|
||||||
const isRemoteGroupSelected = computed(() => selectedGroup.value?.type === 'remote');
|
const isRemoteGroupSelected = computed(() => selectedGroup.value?.type === 'remote');
|
||||||
|
|
||||||
|
const closeFriendToolbarMenu = () => {
|
||||||
|
friendToolbarMenuRef.value?.handleClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleFriendImportClick() {
|
||||||
|
closeFriendToolbarMenu();
|
||||||
|
showFriendImportDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFriendExportClick() {
|
||||||
|
closeFriendToolbarMenu();
|
||||||
|
showFriendExportDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
loadFriendSplitterPreferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFriendSplitterPreferences() {
|
||||||
|
const storedSize = await configRepository.getString('VRCX_FavoritesFriendSplitter', '260');
|
||||||
|
if (typeof storedSize === 'string' && !Number.isNaN(Number(storedSize)) && Number(storedSize) > 0) {
|
||||||
|
friendSplitterSize.value = Number(storedSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFriendSplitterResize(panelIndex, sizes) {
|
||||||
|
if (!Array.isArray(sizes) || !sizes.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSize = sizes[0];
|
||||||
|
if (nextSize <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
friendSplitterSize.value = nextSize;
|
||||||
|
configRepository.setString('VRCX_FavoritesFriendSplitter', nextSize.toString());
|
||||||
|
}
|
||||||
|
|
||||||
const remoteGroupMenuKey = (key) => `remote:${key}`;
|
const remoteGroupMenuKey = (key) => `remote:${key}`;
|
||||||
|
|
||||||
const searchableFriendEntries = computed(() => {
|
const searchableFriendEntries = computed(() => {
|
||||||
@@ -649,6 +722,10 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.group-section {
|
.group-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -861,15 +938,23 @@
|
|||||||
|
|
||||||
.favorites-search-grid {
|
.favorites-search-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 12px;
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 240px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorites-card-list {
|
.favorites-card-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 12px;
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 260px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
padding: 4px 2px 12px 2px;
|
padding: 4px 2px 12px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,22 +963,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card--friend) {
|
:deep(.favorites-search-card--friend) {
|
||||||
flex: 0 1 280px;
|
min-width: var(--favorites-card-min-width, 240px);
|
||||||
min-width: 240px;
|
max-width: var(--favorites-card-target-width, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card) {
|
:deep(.favorites-search-card) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: 8px;
|
border-radius: calc(8px * var(--favorites-card-scale, 1));
|
||||||
padding: 8px 10px;
|
padding: calc(8px * var(--favorites-card-scale, 1)) calc(10px * var(--favorites-card-scale, 1));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
box-shadow 0.2s ease;
|
box-shadow 0.2s ease;
|
||||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
||||||
|
width: 100%;
|
||||||
|
min-width: var(--favorites-card-min-width, 240px);
|
||||||
|
max-width: var(--favorites-card-target-width, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card:hover) {
|
:deep(.favorites-search-card:hover) {
|
||||||
@@ -909,15 +998,15 @@
|
|||||||
:deep(.favorites-search-card__content) {
|
:deep(.favorites-search-card__content) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: calc(10px * var(--favorites-card-scale, 1));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card__avatar) {
|
:deep(.favorites-search-card__avatar) {
|
||||||
width: 48px;
|
width: calc(48px * var(--favorites-card-scale, 1));
|
||||||
height: 48px;
|
height: calc(48px * var(--favorites-card-scale, 1));
|
||||||
border-radius: 6px;
|
border-radius: calc(6px * var(--favorites-card-scale, 1));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--el-fill-color-lighter);
|
background: var(--el-fill-color-lighter);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -943,7 +1032,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 13px;
|
font-size: calc(13px * var(--favorites-card-scale, 1));
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +1044,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card__detail .extra) {
|
:deep(.favorites-search-card__detail .extra) {
|
||||||
font-size: 12px;
|
font-size: calc(12px * var(--favorites-card-scale, 1));
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1037,4 +1126,35 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale {
|
||||||
|
list-style: none;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
min-width: 220px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__slider {
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__slider :deep(.el-slider__runway) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,14 +17,27 @@
|
|||||||
class="favorites-toolbar__search"
|
class="favorites-toolbar__search"
|
||||||
:placeholder="t('view.favorite.worlds.search')"
|
:placeholder="t('view.favorite.worlds.search')"
|
||||||
@input="searchWorldFavorites" />
|
@input="searchWorldFavorites" />
|
||||||
<el-dropdown trigger="click" :hide-on-click="true">
|
<el-dropdown ref="worldToolbarMenuRef" trigger="click" :hide-on-click="false">
|
||||||
<el-button :icon="MoreFilled" size="small" circle />
|
<el-button :icon="MoreFilled" size="small" circle />
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu class="favorites-dropdown">
|
||||||
<el-dropdown-item @click="showWorldImportDialog">
|
<li class="favorites-dropdown__scale" @click.stop>
|
||||||
|
<div class="favorites-dropdown__scale-header">
|
||||||
|
<span>Scale</span>
|
||||||
|
<span class="favorites-dropdown__scale-value">{{ worldCardScalePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<el-slider
|
||||||
|
v-model="worldCardScale"
|
||||||
|
class="favorites-dropdown__slider"
|
||||||
|
:min="worldCardScaleSlider.min"
|
||||||
|
:max="worldCardScaleSlider.max"
|
||||||
|
:step="worldCardScaleSlider.step"
|
||||||
|
:show-tooltip="false" />
|
||||||
|
</li>
|
||||||
|
<el-dropdown-item @click="handleWorldImportClick">
|
||||||
{{ t('view.favorite.import') }}
|
{{ t('view.favorite.import') }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item divided @click="showExportDialog">
|
<el-dropdown-item divided @click="handleWorldExportClick">
|
||||||
{{ t('view.favorite.export') }}
|
{{ t('view.favorite.export') }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
@@ -32,8 +45,8 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-splitter class="favorites-splitter">
|
<el-splitter class="favorites-splitter" @resize-end="handleWorldSplitterResize">
|
||||||
<el-splitter-panel :size="260" :min="0" :max="360" collapsible>
|
<el-splitter-panel :size="worldSplitterSize" :min="0" :max="360" collapsible>
|
||||||
<div class="favorites-groups-panel">
|
<div class="favorites-groups-panel">
|
||||||
<div class="group-section">
|
<div class="group-section">
|
||||||
<div class="group-section__header">
|
<div class="group-section__header">
|
||||||
@@ -282,10 +295,13 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="favorites-content__list">
|
<div ref="worldFavoritesContainerRef" class="favorites-content__list">
|
||||||
<template v-if="isSearchActive">
|
<template v-if="isSearchActive">
|
||||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<div v-if="worldFavoriteSearchResults.length" class="favorites-search-grid">
|
<div
|
||||||
|
v-if="worldFavoriteSearchResults.length"
|
||||||
|
class="favorites-search-grid"
|
||||||
|
:style="worldFavoritesGridStyle(worldFavoriteSearchResults.length)">
|
||||||
<div
|
<div
|
||||||
v-for="favorite in worldFavoriteSearchResults"
|
v-for="favorite in worldFavoriteSearchResults"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -320,7 +336,9 @@
|
|||||||
v-if="activeRemoteGroup && isRemoteGroupSelected"
|
v-if="activeRemoteGroup && isRemoteGroupSelected"
|
||||||
class="favorites-content__scroll favorites-content__scroll--native">
|
class="favorites-content__scroll favorites-content__scroll--native">
|
||||||
<template v-if="currentRemoteFavorites.length">
|
<template v-if="currentRemoteFavorites.length">
|
||||||
<div class="favorites-card-list">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="worldFavoritesGridStyle(currentRemoteFavorites.length)">
|
||||||
<FavoritesWorldItem
|
<FavoritesWorldItem
|
||||||
v-for="favorite in currentRemoteFavorites"
|
v-for="favorite in currentRemoteFavorites"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -340,7 +358,9 @@
|
|||||||
class="favorites-content__scroll"
|
class="favorites-content__scroll"
|
||||||
@scroll="handleLocalFavoritesScroll">
|
@scroll="handleLocalFavoritesScroll">
|
||||||
<template v-if="currentLocalFavorites.length">
|
<template v-if="currentLocalFavorites.length">
|
||||||
<div class="favorites-card-list">
|
<div
|
||||||
|
class="favorites-card-list"
|
||||||
|
:style="worldFavoritesGridStyle(currentLocalFavorites.length)">
|
||||||
<FavoritesWorldLocalItem
|
<FavoritesWorldLocalItem
|
||||||
v-for="favorite in currentLocalFavorites"
|
v-for="favorite in currentLocalFavorites"
|
||||||
:key="favorite.id"
|
:key="favorite.id"
|
||||||
@@ -364,7 +384,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { Loading, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
import { Loading, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -372,10 +392,12 @@
|
|||||||
|
|
||||||
import { useAppearanceSettingsStore, useFavoriteStore, useWorldStore } from '../../stores';
|
import { useAppearanceSettingsStore, useFavoriteStore, useWorldStore } from '../../stores';
|
||||||
import { favoriteRequest, worldRequest } from '../../api';
|
import { favoriteRequest, worldRequest } from '../../api';
|
||||||
|
import { useFavoritesCardScaling } from './composables/useFavoritesCardScaling.js';
|
||||||
|
|
||||||
import FavoritesWorldItem from './components/FavoritesWorldItem.vue';
|
import FavoritesWorldItem from './components/FavoritesWorldItem.vue';
|
||||||
import FavoritesWorldLocalItem from './components/FavoritesWorldLocalItem.vue';
|
import FavoritesWorldLocalItem from './components/FavoritesWorldLocalItem.vue';
|
||||||
import WorldExportDialog from './dialogs/WorldExportDialog.vue';
|
import WorldExportDialog from './dialogs/WorldExportDialog.vue';
|
||||||
|
import configRepository from '../../service/config.js';
|
||||||
|
|
||||||
import * as workerTimers from 'worker-timers';
|
import * as workerTimers from 'worker-timers';
|
||||||
|
|
||||||
@@ -415,7 +437,22 @@
|
|||||||
} = favoriteStore;
|
} = favoriteStore;
|
||||||
const { showWorldDialog } = useWorldStore();
|
const { showWorldDialog } = useWorldStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
cardScale: worldCardScale,
|
||||||
|
slider: worldCardScaleSlider,
|
||||||
|
containerRef: worldFavoritesContainerRef,
|
||||||
|
gridStyle: worldFavoritesGridStyle
|
||||||
|
} = useFavoritesCardScaling({
|
||||||
|
configKey: 'VRCX_FavoritesWorldCardScale',
|
||||||
|
min: 0.6,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01
|
||||||
|
});
|
||||||
|
|
||||||
|
const worldCardScalePercent = computed(() => Math.round(worldCardScale.value * 100));
|
||||||
|
|
||||||
const worldGroupVisibilityOptions = ref(['public', 'friends', 'private']);
|
const worldGroupVisibilityOptions = ref(['public', 'friends', 'private']);
|
||||||
|
const worldSplitterSize = ref(260);
|
||||||
const worldExportDialogVisible = ref(false);
|
const worldExportDialogVisible = ref(false);
|
||||||
const worldFavoriteSearch = ref('');
|
const worldFavoriteSearch = ref('');
|
||||||
const worldFavoriteSearchResults = ref([]);
|
const worldFavoriteSearchResults = ref([]);
|
||||||
@@ -433,6 +470,7 @@
|
|||||||
const worldEditMode = ref(false);
|
const worldEditMode = ref(false);
|
||||||
const activeGroupMenu = ref(null);
|
const activeGroupMenu = ref(null);
|
||||||
const localFavoritesScrollbarRef = ref(null);
|
const localFavoritesScrollbarRef = ref(null);
|
||||||
|
const worldToolbarMenuRef = ref();
|
||||||
const localFavoritesLoadingMore = ref(false);
|
const localFavoritesLoadingMore = ref(false);
|
||||||
const hasWorldSelection = computed(() => selectedFavoriteWorlds.value.length > 0);
|
const hasWorldSelection = computed(() => selectedFavoriteWorlds.value.length > 0);
|
||||||
const hasSearchInput = computed(() => worldFavoriteSearch.value.trim().length > 0);
|
const hasSearchInput = computed(() => worldFavoriteSearch.value.trim().length > 0);
|
||||||
@@ -442,6 +480,43 @@
|
|||||||
const remoteGroupMenuKey = (key) => `remote:${key}`;
|
const remoteGroupMenuKey = (key) => `remote:${key}`;
|
||||||
const localGroupMenuKey = (key) => `local:${key}`;
|
const localGroupMenuKey = (key) => `local:${key}`;
|
||||||
|
|
||||||
|
const closeWorldToolbarMenu = () => {
|
||||||
|
worldToolbarMenuRef.value?.handleClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleWorldImportClick() {
|
||||||
|
closeWorldToolbarMenu();
|
||||||
|
showWorldImportDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorldExportClick() {
|
||||||
|
closeWorldToolbarMenu();
|
||||||
|
showExportDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
loadWorldSplitterPreferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadWorldSplitterPreferences() {
|
||||||
|
const storedSize = await configRepository.getString('VRCX_FavoritesWorldSplitter', '260');
|
||||||
|
if (typeof storedSize === 'string' && !Number.isNaN(Number(storedSize)) && Number(storedSize) > 0) {
|
||||||
|
worldSplitterSize.value = Number(storedSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorldSplitterResize(panelIndex, sizes) {
|
||||||
|
if (!Array.isArray(sizes) || !sizes.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSize = sizes[0];
|
||||||
|
if (nextSize <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worldSplitterSize.value = nextSize;
|
||||||
|
configRepository.setString('VRCX_FavoritesWorldSplitter', nextSize.toString());
|
||||||
|
}
|
||||||
|
|
||||||
const groupedWorldFavorites = computed(() => {
|
const groupedWorldFavorites = computed(() => {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
favoriteWorlds.value.forEach((world) => {
|
favoriteWorlds.value.forEach((world) => {
|
||||||
@@ -1118,6 +1193,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-groups-panel {
|
.favorites-groups-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
@@ -1374,15 +1453,23 @@
|
|||||||
|
|
||||||
.favorites-search-grid {
|
.favorites-search-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 12px;
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 240px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorites-card-list {
|
.favorites-card-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 12px;
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 260px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
padding: 4px 2px 12px 2px;
|
padding: 4px 2px 12px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,22 +1478,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card--world) {
|
:deep(.favorites-search-card--world) {
|
||||||
flex: 0 1 280px;
|
min-width: var(--favorites-card-min-width, 240px);
|
||||||
min-width: 240px;
|
max-width: var(--favorites-card-target-width, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card) {
|
:deep(.favorites-search-card) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: 8px;
|
border-radius: calc(8px * var(--favorites-card-scale, 1));
|
||||||
padding: 8px 10px;
|
padding: calc(8px * var(--favorites-card-scale, 1)) calc(10px * var(--favorites-card-scale, 1));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
box-shadow 0.2s ease;
|
box-shadow 0.2s ease;
|
||||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
||||||
|
width: 100%;
|
||||||
|
min-width: var(--favorites-card-min-width, 240px);
|
||||||
|
max-width: var(--favorites-card-target-width, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card:hover) {
|
:deep(.favorites-search-card:hover) {
|
||||||
@@ -1422,15 +1513,15 @@
|
|||||||
:deep(.favorites-search-card__content) {
|
:deep(.favorites-search-card__content) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: calc(10px * var(--favorites-card-scale, 1));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card__avatar) {
|
:deep(.favorites-search-card__avatar) {
|
||||||
width: 48px;
|
width: calc(48px * var(--favorites-card-scale, 1));
|
||||||
height: 48px;
|
height: calc(48px * var(--favorites-card-scale, 1));
|
||||||
border-radius: 6px;
|
border-radius: calc(6px * var(--favorites-card-scale, 1));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--el-fill-color-lighter);
|
background: var(--el-fill-color-lighter);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1456,7 +1547,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 13px;
|
font-size: calc(13px * var(--favorites-card-scale, 1));
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,7 +1559,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.favorites-search-card__detail .extra) {
|
:deep(.favorites-search-card__detail .extra) {
|
||||||
font-size: 12px;
|
font-size: calc(12px * var(--favorites-card-scale, 1));
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1542,4 +1633,35 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale {
|
||||||
|
list-style: none;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
min-width: 220px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__scale-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__slider {
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-dropdown__slider :deep(.el-slider__runway) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
218
src/views/Favorites/composables/useFavoritesCardScaling.js
Normal file
218
src/views/Favorites/composables/useFavoritesCardScaling.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeMount,
|
||||||
|
onBeforeUnmount,
|
||||||
|
ref,
|
||||||
|
watch
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import configRepository from '../../../service/config.js';
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
if (value < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
if (value > max) {
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFavoritesCardScaling(options = {}) {
|
||||||
|
const slider = {
|
||||||
|
min: options.min ?? 0.6,
|
||||||
|
max: options.max ?? 1,
|
||||||
|
step: options.step ?? 0.01
|
||||||
|
};
|
||||||
|
const baseWidth = options.baseWidth ?? 260;
|
||||||
|
const baseGap = options.baseGap ?? 12;
|
||||||
|
const gapStep = options.gapStep ?? 8;
|
||||||
|
const configKey = options.configKey ?? '';
|
||||||
|
|
||||||
|
const cardScaleBase = ref(1);
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const containerWidth = ref(0);
|
||||||
|
|
||||||
|
let resizeObserver;
|
||||||
|
let cleanupWindowResize;
|
||||||
|
|
||||||
|
const updateContainerWidth = (el = containerRef.value) => {
|
||||||
|
const element = el ?? containerRef.value;
|
||||||
|
if (!element) {
|
||||||
|
containerWidth.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
containerWidth.value = Math.max(
|
||||||
|
element.clientWidth ?? element.offsetWidth ?? 0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectResizeObserver = () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = undefined;
|
||||||
|
}
|
||||||
|
if (cleanupWindowResize) {
|
||||||
|
cleanupWindowResize();
|
||||||
|
cleanupWindowResize = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardScale = computed({
|
||||||
|
get: () => cardScaleBase.value,
|
||||||
|
set: (value) => {
|
||||||
|
const nextValue = clamp(Number(value) || 1, slider.min, slider.max);
|
||||||
|
cardScaleBase.value = nextValue;
|
||||||
|
if (configKey) {
|
||||||
|
configRepository.setString(configKey, nextValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridStyle = computed(() => {
|
||||||
|
const minWidth = baseWidth * cardScale.value;
|
||||||
|
const rawGap = baseGap + (cardScale.value - 1) * gapStep;
|
||||||
|
const gap = Math.max(6, rawGap);
|
||||||
|
|
||||||
|
return (count = 1, options = {}) => {
|
||||||
|
const width = Math.max(containerWidth.value ?? 0, 0);
|
||||||
|
const itemCount = Math.max(Number(count) || 0, 0);
|
||||||
|
const safeCount = itemCount > 0 ? itemCount : 1;
|
||||||
|
const maxColumns =
|
||||||
|
width > 0
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((width + gap) / (minWidth + gap)) || 1
|
||||||
|
)
|
||||||
|
: 1;
|
||||||
|
const preferredColumns = options?.preferredColumns;
|
||||||
|
const requestedColumns = preferredColumns
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(Math.round(preferredColumns), maxColumns)
|
||||||
|
)
|
||||||
|
: maxColumns;
|
||||||
|
const columns = Math.max(1, Math.min(safeCount, requestedColumns));
|
||||||
|
const forceStretch = Boolean(options?.forceStretch);
|
||||||
|
const disableAutoStretch = Boolean(options?.disableAutoStretch);
|
||||||
|
const matchMaxColumnWidth = Boolean(
|
||||||
|
options?.matchMaxColumnWidth
|
||||||
|
);
|
||||||
|
const shouldStretch =
|
||||||
|
!disableAutoStretch &&
|
||||||
|
(forceStretch || itemCount >= maxColumns);
|
||||||
|
|
||||||
|
let cardWidth = minWidth;
|
||||||
|
const maxColumnWidth =
|
||||||
|
maxColumns > 0
|
||||||
|
? (width - gap * (maxColumns - 1)) / maxColumns
|
||||||
|
: minWidth;
|
||||||
|
|
||||||
|
if (shouldStretch && columns > 0) {
|
||||||
|
const columnsWidth = width - gap * (columns - 1);
|
||||||
|
const rawWidth =
|
||||||
|
columnsWidth > 0 ? columnsWidth / columns : minWidth;
|
||||||
|
if (Number.isFinite(rawWidth) && rawWidth > 0) {
|
||||||
|
cardWidth = Math.max(minWidth, rawWidth);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
matchMaxColumnWidth &&
|
||||||
|
Number.isFinite(maxColumnWidth) &&
|
||||||
|
maxColumnWidth > 0
|
||||||
|
) {
|
||||||
|
cardWidth = Math.max(minWidth, maxColumnWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'--favorites-card-min-width': `${Math.round(minWidth)}px`,
|
||||||
|
'--favorites-card-gap': `${Math.round(gap)}px`,
|
||||||
|
'--favorites-card-target-width': `${Math.round(cardWidth)}px`,
|
||||||
|
'--favorites-grid-columns': `${columns}`,
|
||||||
|
'--favorites-card-scale': `${cardScale.value.toFixed(2)}`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
containerRef,
|
||||||
|
(element) => {
|
||||||
|
disconnectResizeObserver();
|
||||||
|
if (!element) {
|
||||||
|
containerWidth.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => updateContainerWidth(element));
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const observedElement = element;
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
if (!entries?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [entry] = entries;
|
||||||
|
const width =
|
||||||
|
entry.contentRect?.width ??
|
||||||
|
observedElement?.clientWidth ??
|
||||||
|
0;
|
||||||
|
containerWidth.value = Math.max(width, 0);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const handleResize = () => updateContainerWidth(element);
|
||||||
|
window.addEventListener('resize', handleResize, {
|
||||||
|
passive: true
|
||||||
|
});
|
||||||
|
cleanupWindowResize = () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectResizeObserver();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
if (!configKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedScale = await configRepository.getString(
|
||||||
|
configKey,
|
||||||
|
'1'
|
||||||
|
);
|
||||||
|
const parsedValue = parseFloat(storedScale);
|
||||||
|
if (!Number.isNaN(parsedValue)) {
|
||||||
|
cardScaleBase.value = clamp(
|
||||||
|
parsedValue,
|
||||||
|
slider.min,
|
||||||
|
slider.max
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Failed to load favorites card scale preference',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardScale,
|
||||||
|
slider,
|
||||||
|
containerRef,
|
||||||
|
gridStyle
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user