feat: custom nav menu

This commit is contained in:
pa
2025-11-16 23:12:31 +09:00
committed by Natsumi
parent 672a3ddf51
commit f874473647
3 changed files with 1290 additions and 270 deletions
+427 -96
View File
@@ -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;
} }
} }
+666
View File
@@ -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>
+24 -1
View File
@@ -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": {