mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 14:56:06 +02:00
feat: custom nav menu
This commit is contained in:
+427
-96
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="x-menu-container nav-menu-container">
|
<div class="x-menu-container nav-menu-container">
|
||||||
|
<template v-if="navLayoutReady">
|
||||||
<div>
|
<div>
|
||||||
<div v-if="updateInProgress" class="pending-update" @click="showVRCXUpdateDialog">
|
<div v-if="updateInProgress" class="pending-update" @click="showVRCXUpdateDialog">
|
||||||
<el-progress
|
<el-progress
|
||||||
@@ -20,9 +21,9 @@
|
|||||||
></el-button>
|
></el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu collapse default-active="feed" :collapse-transition="false" ref="navMenuRef">
|
<el-menu collapse :default-active="activeMenuIndex" :collapse-transition="false" ref="navMenuRef">
|
||||||
<el-popover
|
<el-popover
|
||||||
v-for="item in navItems"
|
v-for="item in navMenuItems"
|
||||||
:disabled="!item.entries?.length"
|
:disabled="!item.entries?.length"
|
||||||
:key="item.index"
|
:key="item.index"
|
||||||
:ref="(el) => setNavPopoverRef(el, item.index)"
|
:ref="(el) => setNavPopoverRef(el, item.index)"
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
<div class="nav-menu-popover">
|
<div class="nav-menu-popover">
|
||||||
<div class="nav-menu-popover__header">
|
<div class="nav-menu-popover__header">
|
||||||
<i :class="item.icon"></i>
|
<i :class="item.icon"></i>
|
||||||
<span>{{ t(item.title || '') }}</span>
|
<span>{{ item.titleIsCustom ? item.title : t(item.title || '') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-menu-popover__menu">
|
<div class="nav-menu-popover__menu">
|
||||||
@@ -47,29 +48,21 @@
|
|||||||
v-for="entry in item.entries"
|
v-for="entry in item.entries"
|
||||||
:key="entry.label"
|
:key="entry.label"
|
||||||
type="button"
|
type="button"
|
||||||
class="nav-menu-popover__menu-item"
|
:class="['nav-menu-popover__menu-item', { notify: isEntryNotified(entry) }]"
|
||||||
@click="handleSubmenuClick(entry, item.index)">
|
@click="handleSubmenuClick(entry, item.index)">
|
||||||
<span class="nav-menu-popover__menu-label"
|
<i v-if="entry.icon" :class="entry.icon" class="nav-menu-popover__menu-icon"></i>
|
||||||
>{{ t(entry.label)
|
<span class="nav-menu-popover__menu-label">{{ t(entry.label) }}</span>
|
||||||
}}<span
|
|
||||||
v-if="notifiedMenus.includes(entry.routeName || entry.path.split('/').pop())"
|
|
||||||
class="nav-menu-popover__menu-label-dot"></span
|
|
||||||
></span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
:index="item.index"
|
:index="item.index"
|
||||||
:class="{
|
:class="{ notify: isNavItemNotified(item) }"
|
||||||
notify:
|
@click="handleMenuItemClick(item)">
|
||||||
notifiedMenus.includes(item.index) ||
|
|
||||||
(notifiedMenus.includes('friend-log') && item.index === 'social')
|
|
||||||
}"
|
|
||||||
@click="handleRouteChange(item.index)">
|
|
||||||
<i :class="item.icon"></i>
|
<i :class="item.icon"></i>
|
||||||
<template #title v-if="item.tooltip">
|
<template #title v-if="item.tooltip">
|
||||||
<span>{{ t(item.tooltip) }}</span>
|
<span>{{ item.tooltipIsCustom ? item.tooltip : t(item.tooltip) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -82,8 +75,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-menu-container-bottom">
|
<div class="nav-menu-container-bottom">
|
||||||
<el-tooltip v-if="branch === 'Nightly'" :content="'Feedback'" placement="right"
|
<el-tooltip v-if="branch === 'Nightly'" :show-after="150" :content="'Feedback'" placement="right"
|
||||||
><div class="bottom-button" id="feedback" @click="!sentryErrorReporting && setSentryErrorReporting()">
|
><div
|
||||||
|
class="bottom-button"
|
||||||
|
id="feedback"
|
||||||
|
@click="!sentryErrorReporting && setSentryErrorReporting()">
|
||||||
<i class="ri-feedback-line"></i></div
|
<i class="ri-feedback-line"></i></div
|
||||||
></el-tooltip>
|
></el-tooltip>
|
||||||
|
|
||||||
@@ -122,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div>
|
<div>
|
||||||
<el-tooltip :content="t('nav_tooltip.help_support')" placement="right">
|
<el-tooltip :show-after="150" :content="t('nav_tooltip.help_support')" placement="right">
|
||||||
<div class="bottom-button">
|
<div class="bottom-button">
|
||||||
<i class="ri-question-line"></i>
|
<i class="ri-question-line"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +151,9 @@
|
|||||||
<button type="button" class="nav-menu-settings__item" @click="handleSettingsClick">
|
<button type="button" class="nav-menu-settings__item" @click="handleSettingsClick">
|
||||||
<span>{{ t('nav_tooltip.settings') }}</span>
|
<span>{{ t('nav_tooltip.settings') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="nav-menu-settings__item" @click="handleOpenCustomNavDialog">
|
||||||
|
<span>{{ t('nav_menu.custom_nav.header') }}</span>
|
||||||
|
</button>
|
||||||
<el-popover
|
<el-popover
|
||||||
v-model:visible="themeMenuVisible"
|
v-model:visible="themeMenuVisible"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
@@ -194,11 +193,22 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<CustomNavDialog
|
||||||
|
v-model:visible="customNavDialogVisible"
|
||||||
|
:layout="navLayout"
|
||||||
|
:default-folder-icon="DEFAULT_FOLDER_ICON"
|
||||||
|
@save="handleCustomNavSave"
|
||||||
|
@reset="handleCustomNavReset" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { ElMessageBox, dayjs } from 'element-plus';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@@ -215,100 +225,116 @@
|
|||||||
import { THEME_CONFIG } from '../shared/constants';
|
import { THEME_CONFIG } from '../shared/constants';
|
||||||
import { openExternalLink } from '../shared/utils';
|
import { openExternalLink } from '../shared/utils';
|
||||||
|
|
||||||
|
import CustomNavDialog from './dialogs/CustomNavDialog.vue';
|
||||||
|
import configRepository from '../service/config';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const navItems = [
|
const navDefinitions = [
|
||||||
{
|
{
|
||||||
index: 'feed',
|
key: 'feed',
|
||||||
icon: 'ri-rss-line',
|
icon: 'ri-rss-line',
|
||||||
tooltip: 'nav_tooltip.feed'
|
tooltip: 'nav_tooltip.feed',
|
||||||
|
labelKey: 'nav_tooltip.feed',
|
||||||
|
routeName: 'feed'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'friends-locations',
|
key: 'friends-locations',
|
||||||
icon: 'ri-user-location-line',
|
icon: 'ri-user-location-line',
|
||||||
tooltip: 'nav_tooltip.friends_locations'
|
tooltip: 'nav_tooltip.friends_locations',
|
||||||
|
labelKey: 'nav_tooltip.friends_locations',
|
||||||
|
routeName: 'friends-locations'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'game-log',
|
key: 'game-log',
|
||||||
icon: 'ri-history-line',
|
icon: 'ri-history-line',
|
||||||
tooltip: 'nav_tooltip.game_log'
|
tooltip: 'nav_tooltip.game_log',
|
||||||
|
labelKey: 'nav_tooltip.game_log',
|
||||||
|
routeName: 'game-log'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'player-list',
|
key: 'player-list',
|
||||||
icon: 'ri-group-3-line',
|
icon: 'ri-group-3-line',
|
||||||
tooltip: 'nav_tooltip.player_list'
|
tooltip: 'nav_tooltip.player_list',
|
||||||
|
labelKey: 'nav_tooltip.player_list',
|
||||||
|
routeName: 'player-list'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'search',
|
key: 'search',
|
||||||
icon: 'ri-search-line',
|
icon: 'ri-search-line',
|
||||||
tooltip: 'nav_tooltip.search'
|
tooltip: 'nav_tooltip.search',
|
||||||
|
labelKey: 'nav_tooltip.search',
|
||||||
|
routeName: 'search'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'favorites',
|
key: 'favorite-friends',
|
||||||
icon: 'ri-star-line',
|
icon: 'ri-heart-2-line',
|
||||||
tooltip: '',
|
tooltip: 'nav_tooltip.favorite_friends',
|
||||||
title: 'nav_tooltip.favorites',
|
labelKey: 'nav_tooltip.favorite_friends',
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
label: 'view.favorite.friends.header',
|
|
||||||
path: '/favorites/friends',
|
|
||||||
routeName: 'favorite-friends'
|
routeName: 'favorite-friends'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'view.favorite.worlds.header',
|
key: 'favorite-worlds',
|
||||||
path: '/favorites/worlds',
|
icon: 'ri-earth-line',
|
||||||
|
tooltip: 'nav_tooltip.favorite_worlds',
|
||||||
|
labelKey: 'nav_tooltip.favorite_worlds',
|
||||||
routeName: 'favorite-worlds'
|
routeName: 'favorite-worlds'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'view.favorite.avatars.header',
|
key: 'favorite-avatars',
|
||||||
path: '/favorites/avatars',
|
icon: 'ri-user-heart-line',
|
||||||
|
tooltip: 'nav_tooltip.favorite_avatars',
|
||||||
|
labelKey: 'nav_tooltip.favorite_avatars',
|
||||||
routeName: 'favorite-avatars'
|
routeName: 'favorite-avatars'
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'social',
|
key: 'friend-log',
|
||||||
icon: 'ri-group-line',
|
icon: 'ri-booklet-line',
|
||||||
tooltip: '',
|
tooltip: 'nav_tooltip.friend_log',
|
||||||
title: 'nav_tooltip.social',
|
labelKey: 'nav_tooltip.friend_log',
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
label: 'nav_tooltip.friend_log',
|
|
||||||
path: '/social/friend-log',
|
|
||||||
routeName: 'friend-log'
|
routeName: 'friend-log'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav_tooltip.friend_list',
|
key: 'friend-list',
|
||||||
path: '/social/friend-list',
|
icon: 'ri-group-line',
|
||||||
|
tooltip: 'nav_tooltip.friend_list',
|
||||||
|
labelKey: 'nav_tooltip.friend_list',
|
||||||
routeName: 'friend-list'
|
routeName: 'friend-list'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav_tooltip.moderation',
|
key: 'moderation',
|
||||||
path: '/social/moderation',
|
icon: 'ri-shield-user-line',
|
||||||
|
tooltip: 'nav_tooltip.moderation',
|
||||||
|
labelKey: 'nav_tooltip.moderation',
|
||||||
routeName: 'moderation'
|
routeName: 'moderation'
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
index: 'notification',
|
key: 'notification',
|
||||||
icon: 'ri-notification-2-line',
|
icon: 'ri-notification-2-line',
|
||||||
tooltip: 'nav_tooltip.notification'
|
tooltip: 'nav_tooltip.notification',
|
||||||
|
labelKey: 'nav_tooltip.notification',
|
||||||
|
routeName: 'notification'
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
index: 'charts',
|
key: 'charts',
|
||||||
icon: 'ri-bar-chart-line',
|
icon: 'ri-bar-chart-line',
|
||||||
tooltip: 'nav_tooltip.charts'
|
tooltip: 'nav_tooltip.charts',
|
||||||
|
labelKey: 'nav_tooltip.charts',
|
||||||
|
routeName: 'charts'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 'tools',
|
key: 'tools',
|
||||||
icon: 'ri-tools-line',
|
icon: 'ri-tools-line',
|
||||||
tooltip: 'nav_tooltip.tools'
|
tooltip: 'nav_tooltip.tools',
|
||||||
|
labelKey: 'nav_tooltip.tools',
|
||||||
|
routeName: 'tools'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
||||||
|
const DEFAULT_FOLDER_ICON = 'ri-menu-fold-line';
|
||||||
|
|
||||||
const navPopoverWidth = 250;
|
const navPopoverWidth = 250;
|
||||||
const navPopoverStyle = {
|
const navPopoverStyle = {
|
||||||
zIndex: 500,
|
zIndex: 500,
|
||||||
@@ -328,7 +354,7 @@
|
|||||||
storeToRefs(VRCXUpdaterStore);
|
storeToRefs(VRCXUpdaterStore);
|
||||||
const { showVRCXUpdateDialog, updateProgressText, showChangeLogDialog } = VRCXUpdaterStore;
|
const { showVRCXUpdateDialog, updateProgressText, showChangeLogDialog } = VRCXUpdaterStore;
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
const { notifiedMenus, lastVisitedSocialRoute, lastVisitedFavoritesRoute } = storeToRefs(uiStore);
|
const { notifiedMenus } = storeToRefs(uiStore);
|
||||||
const { directAccessPaste } = useSearchStore();
|
const { directAccessPaste } = useSearchStore();
|
||||||
const { sentryErrorReporting } = storeToRefs(useAdvancedSettingsStore());
|
const { sentryErrorReporting } = storeToRefs(useAdvancedSettingsStore());
|
||||||
const { setSentryErrorReporting } = useAdvancedSettingsStore();
|
const { setSentryErrorReporting } = useAdvancedSettingsStore();
|
||||||
@@ -343,11 +369,184 @@
|
|||||||
const navMenuRef = ref(null);
|
const navMenuRef = ref(null);
|
||||||
const navPopoverRefs = new Map();
|
const navPopoverRefs = new Map();
|
||||||
|
|
||||||
|
const navLayout = ref([]);
|
||||||
|
const navLayoutReady = ref(false);
|
||||||
|
|
||||||
|
const navMenuItems = computed(() => {
|
||||||
|
const items = [];
|
||||||
|
navLayout.value.forEach((entry) => {
|
||||||
|
if (entry.type === 'item') {
|
||||||
|
const definition = navDefinitionMap.get(entry.key);
|
||||||
|
if (!definition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
...definition,
|
||||||
|
index: definition.key,
|
||||||
|
tooltipIsCustom: false,
|
||||||
|
titleIsCustom: false
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'folder') {
|
||||||
|
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
|
||||||
|
|
||||||
|
if (folderDefinitions.length < 2) {
|
||||||
|
folderDefinitions.forEach((definition) => {
|
||||||
|
items.push({
|
||||||
|
...definition,
|
||||||
|
index: definition.key,
|
||||||
|
tooltipIsCustom: false,
|
||||||
|
titleIsCustom: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderEntries = folderDefinitions.map((definition) => ({
|
||||||
|
label: definition.labelKey,
|
||||||
|
routeName: definition.routeName,
|
||||||
|
key: definition.key,
|
||||||
|
icon: definition.icon
|
||||||
|
}));
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
index: entry.id,
|
||||||
|
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||||
|
tooltip: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
|
||||||
|
tooltipIsCustom: true,
|
||||||
|
title: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
|
||||||
|
titleIsCustom: true,
|
||||||
|
entries: folderEntries
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeMenuIndex = computed(() => {
|
||||||
|
const currentRouteName = router.currentRoute.value?.name;
|
||||||
|
if (!currentRouteName) {
|
||||||
|
const firstEntry = navLayout.value[0];
|
||||||
|
if (!firstEntry) {
|
||||||
|
return 'feed';
|
||||||
|
}
|
||||||
|
return firstEntry.type === 'folder' ? firstEntry.id : firstEntry.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of navLayout.value) {
|
||||||
|
if (entry.type === 'item' && entry.key === currentRouteName) {
|
||||||
|
return entry.key;
|
||||||
|
}
|
||||||
|
if (entry.type === 'folder' && entry.items?.includes(currentRouteName)) {
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = navLayout.value[0];
|
||||||
|
if (!fallback) {
|
||||||
|
return 'feed';
|
||||||
|
}
|
||||||
|
return fallback.type === 'folder' ? fallback.id : fallback.key;
|
||||||
|
});
|
||||||
|
|
||||||
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
|
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
|
||||||
const vrcxLogo = new URL('../../images/VRCX.png', import.meta.url).href;
|
const vrcxLogo = new URL('../../images/VRCX.png', import.meta.url).href;
|
||||||
|
|
||||||
const themes = computed(() => Object.keys(THEME_CONFIG));
|
const themes = computed(() => Object.keys(THEME_CONFIG));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => activeMenuIndex.value,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
navMenuRef.value?.updateActiveIndex(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateFolderId = () => `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
||||||
|
|
||||||
|
const defaultNavLayout = [
|
||||||
|
{ type: 'item', key: 'feed' },
|
||||||
|
{ type: 'item', key: 'friends-locations' },
|
||||||
|
{ type: 'item', key: 'game-log' },
|
||||||
|
{ type: 'item', key: 'player-list' },
|
||||||
|
{ type: 'item', key: 'search' },
|
||||||
|
{
|
||||||
|
type: 'folder',
|
||||||
|
id: 'default-folder-favorites',
|
||||||
|
name: t('nav_tooltip.favorites'),
|
||||||
|
icon: 'ri-star-line',
|
||||||
|
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'folder',
|
||||||
|
id: 'default-folder-social',
|
||||||
|
name: t('nav_tooltip.social'),
|
||||||
|
icon: 'ri-group-line',
|
||||||
|
items: ['friend-log', 'friend-list', 'moderation']
|
||||||
|
},
|
||||||
|
{ type: 'item', key: 'notification' },
|
||||||
|
{ type: 'item', key: 'charts' },
|
||||||
|
{ type: 'item', key: 'tools' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sanitizeLayout = (layout) => {
|
||||||
|
const usedKeys = new Set();
|
||||||
|
const normalized = [];
|
||||||
|
|
||||||
|
const appendItemEntry = (key, target = normalized) => {
|
||||||
|
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.push({ type: 'item', key });
|
||||||
|
usedKeys.add(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(layout)) {
|
||||||
|
layout.forEach((entry) => {
|
||||||
|
if (entry?.type === 'item') {
|
||||||
|
appendItemEntry(entry.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry?.type === 'folder') {
|
||||||
|
const folderItems = [];
|
||||||
|
(entry.items || []).forEach((key) => {
|
||||||
|
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
folderItems.push(key);
|
||||||
|
usedKeys.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folderItems.length >= 2) {
|
||||||
|
normalized.push({
|
||||||
|
type: 'folder',
|
||||||
|
id: entry.id || generateFolderId(),
|
||||||
|
name: entry.name || '',
|
||||||
|
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||||
|
items: folderItems
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
folderItems.forEach((key) => appendItemEntry(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
navDefinitions.forEach((item) => {
|
||||||
|
if (!usedKeys.has(item.key)) {
|
||||||
|
normalized.push({ type: 'item', key: item.key });
|
||||||
|
usedKeys.add(item.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
const themeDisplayName = (key) => THEME_CONFIG[key]?.name ?? key;
|
const themeDisplayName = (key) => THEME_CONFIG[key]?.name ?? key;
|
||||||
|
|
||||||
const handleSettingsClick = () => {
|
const handleSettingsClick = () => {
|
||||||
@@ -372,6 +571,72 @@
|
|||||||
openExternalLink('https://github.com/vrcx-team/VRCX');
|
openExternalLink('https://github.com/vrcx-team/VRCX');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customNavDialogVisible = ref(false);
|
||||||
|
|
||||||
|
const saveNavLayout = async (layout) => {
|
||||||
|
try {
|
||||||
|
await configRepository.setString(
|
||||||
|
'VRCX_customNavMenuLayoutList',
|
||||||
|
JSON.stringify({
|
||||||
|
layout
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save custom nav', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCustomNavDialog = () => {
|
||||||
|
themeMenuVisible.value = false;
|
||||||
|
supportMenuVisible.value = false;
|
||||||
|
settingsMenuVisible.value = false;
|
||||||
|
customNavDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomNavSave = async (layout) => {
|
||||||
|
const sanitized = sanitizeLayout(layout);
|
||||||
|
navLayout.value = sanitized;
|
||||||
|
await saveNavLayout(sanitized);
|
||||||
|
customNavDialogVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomNavReset = () => {
|
||||||
|
ElMessageBox.confirm(t('nav_menu.custom_nav.restore_default_confirm'), {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: t('nav_menu.custom_nav.restore_default'),
|
||||||
|
cancelButtonText: t('nav_menu.custom_nav.cancel')
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
const defaults = sanitizeLayout(defaultNavLayout);
|
||||||
|
navLayout.value = defaults;
|
||||||
|
await saveNavLayout(defaults);
|
||||||
|
customNavDialogVisible.value = false;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadNavMenuConfig = async () => {
|
||||||
|
let layoutData = null;
|
||||||
|
try {
|
||||||
|
const storedValue = await configRepository.getString('VRCX_customNavMenuLayoutList');
|
||||||
|
if (storedValue) {
|
||||||
|
const parsed = JSON.parse(storedValue);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
layoutData = parsed;
|
||||||
|
} else if (Array.isArray(parsed?.layout)) {
|
||||||
|
layoutData = parsed.layout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load custom nav', error);
|
||||||
|
} finally {
|
||||||
|
const fallbackLayout = layoutData?.length ? layoutData : defaultNavLayout;
|
||||||
|
navLayout.value = sanitizeLayout(fallbackLayout);
|
||||||
|
navLayoutReady.value = true;
|
||||||
|
navigateToFirstNavEntry();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (e.ctrlKey && e.key === 'd') {
|
if (e.ctrlKey && e.key === 'd') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -393,6 +658,36 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEntryNotified = (entry) => {
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const targets = [];
|
||||||
|
if (entry.routeName) {
|
||||||
|
targets.push(entry.routeName);
|
||||||
|
}
|
||||||
|
if (entry.path) {
|
||||||
|
const lastSegment = entry.path.split('/').pop();
|
||||||
|
if (lastSegment) {
|
||||||
|
targets.push(lastSegment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets.some((key) => notifiedMenus.value.includes(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNavItemNotified = (item) => {
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (notifiedMenus.value.includes(item.index)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (item.entries?.length) {
|
||||||
|
return item.entries.some((entry) => isEntryNotified(entry));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const setNavPopoverRef = (el, index) => {
|
const setNavPopoverRef = (el, index) => {
|
||||||
if (!index) {
|
if (!index) {
|
||||||
return;
|
return;
|
||||||
@@ -413,11 +708,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry.routeName) {
|
if (entry.routeName) {
|
||||||
router.push({ name: entry.routeName });
|
handleRouteChange(entry.routeName, index || entry.routeName);
|
||||||
} else if (entry.path) {
|
} else if (entry.path) {
|
||||||
router.push(entry.path);
|
router.push(entry.path);
|
||||||
}
|
if (index) {
|
||||||
navMenuRef.value?.updateActiveIndex(index);
|
navMenuRef.value?.updateActiveIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
closeNavPopover(index);
|
closeNavPopover(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,15 +724,14 @@
|
|||||||
themeMenuVisible.value = false;
|
themeMenuVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRouteChange = (index) => {
|
const handleRouteChange = (routeName, navIndex = routeName) => {
|
||||||
let targetName = index;
|
if (!routeName) {
|
||||||
if (index === 'social') {
|
return;
|
||||||
targetName = lastVisitedSocialRoute.value || 'friend-log';
|
}
|
||||||
} else if (index === 'favorites') {
|
router.push({ name: routeName });
|
||||||
targetName = lastVisitedFavoritesRoute.value || 'favorite-friends';
|
if (navIndex) {
|
||||||
|
navMenuRef.value?.updateActiveIndex(navIndex);
|
||||||
}
|
}
|
||||||
router.push({ name: targetName });
|
|
||||||
navMenuRef.value?.updateActiveIndex(index);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(settingsMenuVisible, (visible) => {
|
watch(settingsMenuVisible, (visible) => {
|
||||||
@@ -452,8 +748,44 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
const getFirstNavRoute = (layout) => {
|
||||||
|
for (const entry of layout) {
|
||||||
|
if (entry.type === 'item') {
|
||||||
|
return entry.key;
|
||||||
|
}
|
||||||
|
if (entry.type === 'folder' && entry.items?.length) {
|
||||||
|
return entry.items[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasNavigatedToInitialRoute = false;
|
||||||
|
const navigateToFirstNavEntry = () => {
|
||||||
|
if (hasNavigatedToInitialRoute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstRoute = getFirstNavRoute(navLayout.value);
|
||||||
|
if (!firstRoute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasNavigatedToInitialRoute = true;
|
||||||
|
if (router.currentRoute.value?.name !== firstRoute) {
|
||||||
|
router.push({ name: firstRoute }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuItemClick = (item) => {
|
||||||
|
if (!item || item.entries?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleRouteChange(item.routeName, item.index);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadNavMenuConfig();
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
if (!sentryErrorReporting.value) return;
|
if (!sentryErrorReporting.value) return;
|
||||||
try {
|
try {
|
||||||
import('@sentry/vue').then((Sentry) => {
|
import('@sentry/vue').then((Sentry) => {
|
||||||
@@ -574,9 +906,8 @@
|
|||||||
|
|
||||||
.nav-menu-popover__menu-item {
|
.nav-menu-popover__menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: 8px;
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -589,6 +920,15 @@
|
|||||||
transition: background-color var(--el-transition-duration);
|
transition: background-color var(--el-transition-duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-menu-popover__menu-item.notify::after {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-text-color-primary);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-menu-popover__menu-item:hover {
|
.nav-menu-popover__menu-item:hover {
|
||||||
background-color: var(--el-menu-hover-bg-color);
|
background-color: var(--el-menu-hover-bg-color);
|
||||||
}
|
}
|
||||||
@@ -598,22 +938,13 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-menu-popover__menu-label {
|
.nav-menu-popover__menu-icon {
|
||||||
font-weight: 600;
|
font-size: 16px;
|
||||||
position: relative;
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-menu-popover__menu-label-dot {
|
.nav-menu-popover__menu-label {
|
||||||
position: absolute;
|
font-weight: 600;
|
||||||
right: -8px;
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
background: #303133;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
:global(html.dark),
|
|
||||||
.nav-menu-popover__menu-label-dot {
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,666 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="visible"
|
||||||
|
class="custom-nav-dialog"
|
||||||
|
:title="t('nav_menu.custom_nav.dialog_title')"
|
||||||
|
width="600px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@close="handleClose"
|
||||||
|
destroy-on-close>
|
||||||
|
<div class="custom-nav-dialog__list" v-if="localLayout.length">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in localLayout"
|
||||||
|
:key="entry.key || entry.id"
|
||||||
|
:class="['custom-nav-entry', `custom-nav-entry--${entry.type}`]">
|
||||||
|
<template v-if="entry.type === 'item'">
|
||||||
|
<div class="custom-nav-entry__info">
|
||||||
|
<i :class="definitionsMap.get(entry.key)?.icon"></i>
|
||||||
|
<span>{{ t(definitionsMap.get(entry.key)?.labelKey || entry.key) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="custom-nav-entry__controls">
|
||||||
|
<div class="custom-nav-entry__move">
|
||||||
|
<el-button circle size="small" :disabled="index === 0" @click="handleMoveEntry(index, -1)">
|
||||||
|
<i class="ri-arrow-up-line"></i>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:disabled="index === localLayout.length - 1"
|
||||||
|
@click="handleMoveEntry(index, 1)">
|
||||||
|
<i class="ri-arrow-down-line"></i>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="custom-nav-entry__folder-header">
|
||||||
|
<div class="custom-nav-entry__info">
|
||||||
|
<i :class="entry.icon || defaultFolderIcon"></i>
|
||||||
|
<span>{{ entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="custom-nav-entry__actions">
|
||||||
|
<el-button size="small" plain @click="openFolderEditor(index)">
|
||||||
|
<i class="ri-edit-box-line"></i>
|
||||||
|
{{ t('nav_menu.custom_nav.edit_folder') }}
|
||||||
|
</el-button>
|
||||||
|
<div class="custom-nav-entry__move">
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="handleMoveEntry(index, -1)">
|
||||||
|
<i class="ri-arrow-up-line"></i>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:disabled="index === localLayout.length - 1"
|
||||||
|
@click="handleMoveEntry(index, 1)">
|
||||||
|
<i class="ri-arrow-down-line"></i>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="custom-nav-entry__folder-items">
|
||||||
|
<template v-if="entry.items?.length">
|
||||||
|
<el-tag
|
||||||
|
v-for="key in entry.items"
|
||||||
|
:key="`${entry.id}-${key}`"
|
||||||
|
size="small"
|
||||||
|
class="custom-nav-entry__folder-tag">
|
||||||
|
{{ t(definitionsMap.get(key)?.labelKey || key) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else class="custom-nav-entry__folder-empty">
|
||||||
|
{{ t('nav_menu.custom_nav.folder_empty') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="invalidFolders.length"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
:title="t('nav_menu.custom_nav.invalid_folder')" />
|
||||||
|
<template #footer>
|
||||||
|
<div class="custom-nav-dialog__footer">
|
||||||
|
<div class="custom-nav-dialog__footer-left">
|
||||||
|
<el-button size="small" type="primary" plain @click="openFolderEditor()">
|
||||||
|
{{ t('nav_menu.custom_nav.add_folder') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="handleReset">
|
||||||
|
{{ t('nav_menu.custom_nav.restore_default') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="custom-nav-dialog__footer-right">
|
||||||
|
<el-button @click="handleClose">
|
||||||
|
{{ t('nav_menu.custom_nav.cancel') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :disabled="isSaveDisabled" @click="handleSave">
|
||||||
|
{{ t('nav_menu.custom_nav.save') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="folderEditor.visible"
|
||||||
|
class="folder-editor-dialog"
|
||||||
|
:title="folderEditor.isEditing ? t('nav_menu.custom_nav.edit_folder') : t('nav_menu.custom_nav.add_folder')"
|
||||||
|
width="900px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close>
|
||||||
|
<div class="folder-editor">
|
||||||
|
<div class="folder-editor__form">
|
||||||
|
<el-input
|
||||||
|
v-model="folderEditor.data.name"
|
||||||
|
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
|
||||||
|
<el-input
|
||||||
|
v-model="folderEditor.data.icon"
|
||||||
|
:placeholder="t('nav_menu.custom_nav.folder_icon_placeholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="folder-editor__lists">
|
||||||
|
<div class="folder-editor__column">
|
||||||
|
<div class="folder-editor__column-title">
|
||||||
|
{{ t('nav_menu.custom_nav.folder_available') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!folderEditorAvailableItems.length" class="folder-editor__empty">
|
||||||
|
{{ t('nav_menu.custom_nav.folder_empty') }}
|
||||||
|
</div>
|
||||||
|
<el-scrollbar v-else always class="folder-editor__scroll">
|
||||||
|
<div v-for="item in folderEditorAvailableItems" :key="item.key" class="folder-editor__option">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="folderEditor.data.items.includes(item.key)"
|
||||||
|
@change="(val) => toggleFolderItem(item.key, val)">
|
||||||
|
<span class="folder-editor__option-label">
|
||||||
|
<i :class="item.icon"></i>
|
||||||
|
{{ t(item.labelKey) }}
|
||||||
|
</span>
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
<div class="folder-editor__column folder-editor__column--selected">
|
||||||
|
<div class="folder-editor__column-title">
|
||||||
|
{{ t('nav_menu.custom_nav.folder_selected') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!folderEditor.data.items.length" class="folder-editor__empty">
|
||||||
|
{{ t('nav_menu.custom_nav.folder_selected_empty') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(key, index) in folderEditor.data.items"
|
||||||
|
:key="`selected-${key}`"
|
||||||
|
class="folder-editor__selected-item">
|
||||||
|
<div class="folder-editor__selected-label">
|
||||||
|
<i :class="definitionsMap.get(key)?.icon"></i>
|
||||||
|
<span>{{ t(definitionsMap.get(key)?.labelKey || key) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="folder-editor__selected-actions">
|
||||||
|
<div class="custom-nav-entry__move">
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="handleFolderItemMove(index, -1)">
|
||||||
|
<i class="ri-arrow-up-line"></i>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:disabled="index === folderEditor.data.items.length - 1"
|
||||||
|
@click="handleFolderItemMove(index, 1)">
|
||||||
|
<i class="ri-arrow-down-line"></i>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" text @click="toggleFolderItem(key, false)">
|
||||||
|
{{ t('nav_menu.custom_nav.remove_from_folder') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="folder-editor__footer">
|
||||||
|
<el-button
|
||||||
|
v-if="folderEditor.isEditing"
|
||||||
|
type="danger"
|
||||||
|
:disabled="!canDeleteFolder"
|
||||||
|
@click="handleFolderEditorDelete">
|
||||||
|
{{ t('nav_menu.custom_nav.delete_folder') }}
|
||||||
|
</el-button>
|
||||||
|
<div class="folder-editor__footer-spacer"></div>
|
||||||
|
<el-button @click="closeFolderEditor">
|
||||||
|
{{ t('nav_menu.custom_nav.cancel') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :disabled="folderEditorSaveDisabled" @click="handleFolderEditorSave">
|
||||||
|
{{ t('nav_menu.custom_nav.save') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { navDefinitions } from '../../shared/constants/ui.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
defaultFolderIcon: {
|
||||||
|
type: String,
|
||||||
|
default: 'ri-menu-fold-line'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'save', 'reset']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const cloneLayout = (source) => {
|
||||||
|
if (!Array.isArray(source)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return source.map((entry) => {
|
||||||
|
if (entry?.type === 'folder') {
|
||||||
|
return {
|
||||||
|
type: 'folder',
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
icon: entry.icon || props.defaultFolderIcon,
|
||||||
|
items: Array.isArray(entry.items) ? [...entry.items] : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'item', key: entry.key };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const localLayout = ref(cloneLayout(props.layout));
|
||||||
|
|
||||||
|
const folderEditor = reactive({
|
||||||
|
visible: false,
|
||||||
|
isEditing: false,
|
||||||
|
index: -1,
|
||||||
|
data: {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
icon: '',
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
localLayout.value = cloneLayout(props.layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const definitionsMap = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
navDefinitions.forEach((definition) => {
|
||||||
|
if (definition?.key) {
|
||||||
|
map.set(definition.key, definition);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderEntries = computed(() => localLayout.value.filter((entry) => entry.type === 'folder'));
|
||||||
|
|
||||||
|
const invalidFolders = computed(() =>
|
||||||
|
folderEntries.value.filter((entry) => !entry.name?.trim() || entry.items?.length < 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSaveDisabled = computed(() => invalidFolders.value.length > 0);
|
||||||
|
|
||||||
|
const handleMoveEntry = (index, direction) => {
|
||||||
|
const targetIndex = index + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= localLayout.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = [...localLayout.value];
|
||||||
|
const [entry] = entries.splice(index, 1);
|
||||||
|
entries.splice(targetIndex, 0, entry);
|
||||||
|
localLayout.value = entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const folderEditorSaveDisabled = computed(() => {
|
||||||
|
const nameInvalid = !folderEditor.data.name?.trim();
|
||||||
|
const insufficientItems = folderEditor.data.items.length < 2;
|
||||||
|
return nameInvalid || insufficientItems;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canDeleteFolder = computed(() => {
|
||||||
|
return localLayout.value[folderEditor.index]?.type === 'folder';
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedKeysExcludingEditor = computed(() => {
|
||||||
|
const set = new Set();
|
||||||
|
localLayout.value.forEach((entry) => {
|
||||||
|
if (entry.type !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (folderEditor.isEditing && entry.id === folderEditor.data.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.items?.forEach((key) => set.add(key));
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderEditorAvailableItems = computed(() =>
|
||||||
|
navDefinitions.filter(
|
||||||
|
(definition) =>
|
||||||
|
!usedKeysExcludingEditor.value.has(definition.key) || folderEditor.data.items.includes(definition.key)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const openFolderEditor = (index) => {
|
||||||
|
folderEditor.isEditing = !!index;
|
||||||
|
folderEditor.index = folderEditor.isEditing ? index : -1;
|
||||||
|
if (folderEditor.isEditing) {
|
||||||
|
const entry = localLayout.value[index];
|
||||||
|
folderEditor.data = {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
icon: entry.icon || props.defaultFolderIcon,
|
||||||
|
items: Array.isArray(entry.items) ? [...entry.items] : []
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
folderEditor.data = {
|
||||||
|
id: `custom-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 7)}`,
|
||||||
|
name: '',
|
||||||
|
icon: props.defaultFolderIcon,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
folderEditor.visible = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFolderEditor = () => {
|
||||||
|
folderEditor.visible = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFolderItem = (key, enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
if (!folderEditor.data.items.includes(key)) {
|
||||||
|
folderEditor.data.items.push(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
folderEditor.data.items = folderEditor.data.items.filter((item) => item !== key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderItemMove = (index, direction) => {
|
||||||
|
const targetIndex = index + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= folderEditor.data.items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = [...folderEditor.data.items];
|
||||||
|
const [item] = items.splice(index, 1);
|
||||||
|
items.splice(targetIndex, 0, item);
|
||||||
|
folderEditor.data.items = items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFolderChanges = () => {
|
||||||
|
const sanitizedItems = folderEditor.data.items.filter((key) => definitionsMap.value.has(key));
|
||||||
|
const entries = [...localLayout.value];
|
||||||
|
|
||||||
|
if (folderEditor.isEditing) {
|
||||||
|
const targetIndex = folderEditor.index;
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = entries[targetIndex];
|
||||||
|
if (!existing || existing.type !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removedItems = existing.items.filter((key) => !sanitizedItems.includes(key));
|
||||||
|
entries.splice(targetIndex, 1);
|
||||||
|
|
||||||
|
const filteredEntries = entries.filter(
|
||||||
|
(entry) => !(entry.type === 'item' && sanitizedItems.includes(entry.key))
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredEntries.splice(targetIndex, 0, {
|
||||||
|
type: 'folder',
|
||||||
|
id: folderEditor.data.id,
|
||||||
|
name: folderEditor.data.name.trim(),
|
||||||
|
icon: folderEditor.data.icon || props.defaultFolderIcon,
|
||||||
|
items: sanitizedItems
|
||||||
|
});
|
||||||
|
|
||||||
|
if (removedItems.length) {
|
||||||
|
const insertItems = removedItems.map((key) => ({ type: 'item', key }));
|
||||||
|
filteredEntries.splice(targetIndex + 1, 0, ...insertItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
localLayout.value = filteredEntries;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredEntries = entries.filter(
|
||||||
|
(entry) => !(entry.type === 'item' && sanitizedItems.includes(entry.key))
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredEntries.push({
|
||||||
|
type: 'folder',
|
||||||
|
id: folderEditor.data.id,
|
||||||
|
name: folderEditor.data.name.trim(),
|
||||||
|
icon: folderEditor.data.icon || props.defaultFolderIcon,
|
||||||
|
items: sanitizedItems
|
||||||
|
});
|
||||||
|
|
||||||
|
localLayout.value = filteredEntries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderEditorSave = () => {
|
||||||
|
if (folderEditorSaveDisabled.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFolderChanges();
|
||||||
|
closeFolderEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderEditorDelete = () => {
|
||||||
|
if (!folderEditor.isEditing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = [...localLayout.value];
|
||||||
|
const targetIndex = folderEditor.index;
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const folder = entries[targetIndex];
|
||||||
|
if (!folder || folder.type !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.splice(targetIndex, 1);
|
||||||
|
const restoredItems = (folder.items || []).map((key) => ({ type: 'item', key }));
|
||||||
|
entries.splice(targetIndex, 0, ...restoredItems);
|
||||||
|
localLayout.value = entries;
|
||||||
|
closeFolderEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (isSaveDisabled.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('save', cloneLayout(localLayout.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
emit('reset');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('update:visible', false);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-nav-dialog__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 430px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__info i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__move {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__folder-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__folder-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__folder-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-entry__folder-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-dialog__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-dialog__footer-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-nav-dialog__footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__lists {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__column {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__column-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__scroll {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__option {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__option-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__option-label i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__selected-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__selected-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__selected-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__selected-label i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__selected-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__column--selected {
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-editor__footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
"player_list": "Player List",
|
"player_list": "Player List",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
|
"favorite_friends": "Favorite Friends",
|
||||||
|
"favorite_worlds": "Favorite Worlds",
|
||||||
|
"favorite_avatars": "Favorite Avatars",
|
||||||
"social": "Social",
|
"social": "Social",
|
||||||
"friend_log": "Friend Log",
|
"friend_log": "Friend Log",
|
||||||
"moderation": "Moderation",
|
"moderation": "Moderation",
|
||||||
@@ -26,7 +29,27 @@
|
|||||||
"get_help": "GET HELP",
|
"get_help": "GET HELP",
|
||||||
"github": "VRCX on GitHub",
|
"github": "VRCX on GitHub",
|
||||||
"discord": "Join our Discord",
|
"discord": "Join our Discord",
|
||||||
"whats_new": "What's New?"
|
"whats_new": "What's New?",
|
||||||
|
"custom_nav": {
|
||||||
|
"header": "Custom Navigation Menu",
|
||||||
|
"dialog_title": "Customize Navigation",
|
||||||
|
"add_folder": "Add Folder",
|
||||||
|
"folder_name_placeholder": "Folder name",
|
||||||
|
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
|
||||||
|
"edit_folder": "Edit Folder",
|
||||||
|
"folder_available": "Available items",
|
||||||
|
"folder_selected": "Folder items",
|
||||||
|
"folder_selected_empty": "No items selected",
|
||||||
|
"delete_folder": "Delete Folder",
|
||||||
|
"assign_folder": "Add to folder",
|
||||||
|
"remove_from_folder": "Remove from folder",
|
||||||
|
"folder_empty": "No items in this folder",
|
||||||
|
"invalid_folder": "Folder must have a name and contain at least two items.",
|
||||||
|
"restore_default": "Restore Default",
|
||||||
|
"restore_default_confirm": "Restore navigation to its default order?",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
Reference in New Issue
Block a user