feat: add dashboard

This commit is contained in:
pa
2026-03-12 23:23:27 +09:00
parent 6e8f9543eb
commit e817d7392f
31 changed files with 2765 additions and 894 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@ bun.lock
AGENTS.md
AI_GUIDE.md
CLAUDE.md
.coverage/

View File

@@ -1,833 +0,0 @@
<template>
<Sidebar side="left" variant="sidebar" collapsible="icon">
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarContent class="pt-2" style="container-type: inline-size">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu v-if="navLayoutReady">
<template v-for="item in menuItems" :key="item.index">
<SidebarMenuItem v-if="!item.children?.length">
<SidebarMenuButton
:is-active="activeMenuIndex === item.index"
:tooltip="getItemTooltip(item)"
@click="handleMenuItemClick(item)">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative">
<span
v-if="isNavItemNotified(item)"
class="notify-dot-not-collapsed bg-red-500"
:class="{ '-right-1!': isCollapsed }"
aria-hidden="true"></span>
</i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<span
v-if="item.action === 'direct-access' && !isCollapsed"
class="nav-shortcut-hint ml-auto inline-flex items-center gap-0.5">
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
<Kbd>D</Kbd>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-else>
<DropdownMenu
v-if="isCollapsed"
:open="collapsedDropdownOpenId === item.index"
@update:open="
(value) => handleCollapsedDropdownOpenChange(item.index, value)
">
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isNavItemNotified(item)"
class="notify-dot bg-red-500 -right-1!"
aria-hidden="true"></span
></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem
v-for="entry in item.children"
:key="entry.index"
@select="
(event) =>
handleCollapsedSubmenuSelect(event, entry, item.index)
">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-4 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot bg-red-500 -right-1! top-0.5!"
aria-hidden="true"></span
></i>
<span>{{ t(entry.label) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Collapsible
v-else
class="group/collapsible"
:default-open="
activeMenuIndex &&
item.children?.some((e) => e.index === activeMenuIndex)
">
<template #default="{ open }">
<CollapsibleTrigger as-child>
<SidebarMenuButton
:is-active="
item.children?.some((e) => e.index === activeMenuIndex)
"
:tooltip="
item.titleIsCustom ? item.title : t(item.title || '')
">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isNavItemNotified(item)"
class="notify-dot bg-red-500"
aria-hidden="true"></span
></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<ChevronRight
v-show="!isCollapsed"
class="ml-auto transition-transform"
:class="open ? 'rotate-90' : ''" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem
v-for="entry in item.children"
:key="entry.index">
<SidebarMenuSubButton
:is-active="activeMenuIndex === entry.index"
@click="handleSubmenuClick(entry, item.index)">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-5 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot bg-red-500-right-0.5!"
aria-hidden="true"></span
></i>
<span>{{ t(entry.label) }}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</template>
</Collapsible>
</SidebarMenuItem>
</template>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem :disabled="!hasNotifications" @click="clearAllNotifications">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="handleOpenCustomNavDialog">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SidebarFooter class="px-2 py-3">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="t('nav_tooltip.help_support')">
<i class="ri-question-line inline-flex size-6 items-center justify-center text-lg" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.help_support') }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem @click="showChangeLogDialog">
<span>{{ t('nav_menu.whats_new') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t('nav_menu.resources') }}</DropdownMenuLabel>
<DropdownMenuItem @click="handleSupportLink('wiki')">
<span>{{ t('nav_menu.wiki') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t('nav_menu.get_help') }}</DropdownMenuLabel>
<DropdownMenuItem @click="handleSupportLink('github')">
<span>{{ t('nav_menu.github') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleSupportLink('discord')">
<span>{{ t('nav_menu.discord') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton :tooltip="t('nav_tooltip.toggle_theme')" @click="handleThemeToggle">
<i
:class="isDarkMode ? 'ri-moon-line' : 'ri-sun-line'"
class="inline-flex size-6 items-center justify-center text-[19px]" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.toggle_theme') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="t('nav_tooltip.manage')">
<span class="relative inline-flex size-6 items-center justify-center">
<i class="ri-settings-3-line text-lg" />
<span
v-if="pendingVRCXUpdate || pendingVRCXInstall"
class="absolute top-0.5 -right-1 h-1.5 w-1.5 rounded-full bg-red-500"></span>
</span>
<span v-show="!isCollapsed">{{ t('nav_tooltip.manage') }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-54">
<div class="flex items-center gap-2 px-2 py-1.5">
<img class="h-6 w-6 cursor-pointer" :src="vrcxLogo" alt="VRCX" @click="openGithub" />
<div class="flex min-w-0 flex-col">
<button
type="button"
class="text-left text-sm font-medium truncate flex items-center gap-1"
@click="openGithub">
VRCX
<Heart class="text-primary fill-current stroke-none" />
</button>
<span class="text-xs text-muted-foreground">{{ version }}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="pendingVRCXUpdate || pendingVRCXInstall"
@click="showVRCXUpdateDialog">
<span>{{ t('nav_menu.update_available') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="pendingVRCXUpdate || pendingVRCXInstall" />
<DropdownMenuItem @click="handleSettingsClick">
<span>{{ t('nav_tooltip.settings') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.settings.appearance.appearance.theme_mode') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" align="start" class="w-54">
<DropdownMenuCheckboxItem
v-for="theme in themes"
:key="theme"
:model-value="themeMode === theme"
indicator-position="right"
@select="handleThemeSelect(theme)">
<span>{{ themeDisplayName(theme) }}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel class="px-2 py-2 font-normal">
<div class="flex items-center justify-around">
<TooltipWrapper
v-for="theme in themeColors"
:key="theme.key"
side="top"
:content="themeColorDisplayName(theme)"
:delay-duration="600">
<button
type="button"
:disabled="isApplyingThemeColor"
:aria-pressed="currentThemeColor === theme.key"
:aria-label="themeColorDisplayName(theme)"
:title="themeColorDisplayName(theme)"
@click="handleThemeColorSelect(theme)"
class="h-3.5 w-3.5 shrink-0 rounded-sm transition-transform hover:scale-125"
:class="
currentThemeColor === theme.key
? 'ring-1 ring-ring ring-offset-1 ring-offset-background'
: ''
"
:style="{ backgroundColor: theme.swatch }"></button>
</TooltipWrapper>
</div>
</DropdownMenuLabel>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.settings.appearance.appearance.table_density') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" align="start" class="w-54">
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'standard'"
indicator-position="right"
@select="handleTableDensitySelect('standard')">
<span>{{
t('view.settings.appearance.appearance.table_density_comfortable')
}}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'comfortable'"
indicator-position="right"
@select="handleTableDensitySelect('comfortable')">
<span>{{
t('view.settings.appearance.appearance.table_density_standard')
}}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'compact'"
indicator-position="right"
@select="handleTableDensitySelect('compact')">
<span>{{
t('view.settings.appearance.appearance.table_density_compact')
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="handleOpenCustomNavDialog">
<span>{{ t('nav_menu.custom_nav.header') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" @click="handleLogoutClick">
<span>{{ t('dialog.user.actions.logout') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
:tooltip="isCollapsed ? t('nav_tooltip.expand_menu') : t('nav_tooltip.collapse_menu')"
@click="toggleNavCollapse">
<i class="ri-side-bar-line inline-flex size-6 items-center justify-center text-[19px]" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.collapse_menu') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<CustomNavDialog
v-model:visible="customNavDialogVisible"
:layout="navLayout"
:hidden-keys="navHiddenKeys"
:default-layout="defaultNavLayout"
@save="handleCustomNavSave" />
</template>
<script setup>
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem
} from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronRight, Heart } from 'lucide-vue-next';
import { Kbd } from '@/components/ui/kbd';
import { TooltipWrapper } from '@/components/ui/tooltip';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useThemeColor } from '@/shared/utils/base/ui';
import dayjs from 'dayjs';
import {
useAppearanceSettingsStore,
useAuthStore,
useSearchStore,
useUiStore,
useVRCXUpdaterStore
} from '../stores';
import {
getFirstNavRoute,
isEntryNotified as checkEntryNotified,
normalizeHiddenKeys,
sanitizeLayout
} from './navMenuUtils';
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
import { openExternalLink } from '../shared/utils';
import configRepository from '../services/config';
const CustomNavDialog = defineAsyncComponent(() => import('./dialogs/CustomNavDialog.vue'));
const { t, locale } = useI18n();
const router = useRouter();
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
const getItemTooltip = (item) => {
const label = item.titleIsCustom ? item.title : t(item.title || '');
if (item.action !== 'direct-access') {
return label;
}
return () =>
h('span', { class: 'inline-flex items-center gap-1' }, [
label,
h(Kbd, () => (isMac ? '⌘' : 'Ctrl')),
h(Kbd, () => 'D')
]);
};
const createDefaultNavLayout = () => [
{ 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',
nameKey: 'nav_tooltip.favorites',
name: t('nav_tooltip.favorites'),
icon: 'ri-star-line',
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
},
{
type: 'folder',
id: 'default-folder-social',
nameKey: 'nav_tooltip.social',
name: t('nav_tooltip.social'),
icon: 'ri-group-line',
items: ['friend-log', 'friend-list', 'moderation']
},
{ type: 'item', key: 'notification' },
{ type: 'item', key: 'my-avatars' },
{
type: 'folder',
id: 'default-folder-charts',
nameKey: 'nav_tooltip.charts',
name: t('nav_tooltip.charts'),
icon: 'ri-pie-chart-line',
items: ['charts-instance', 'charts-mutual']
},
{ type: 'item', key: 'tools' },
{ type: 'item', key: 'direct-access' }
];
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
const VRCXUpdaterStore = useVRCXUpdaterStore();
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
const uiStore = useUiStore();
const { notifiedMenus } = storeToRefs(uiStore);
const { clearAllNotifications } = uiStore;
const hasNotifications = computed(() => notifiedMenus.value.length > 0);
const isEntryNotified = (entry) => checkEntryNotified(entry, notifiedMenus.value);
const { directAccessPaste } = useSearchStore();
const { logout } = useAuthStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
const { themeMode, tableDensity, isDarkMode, isNavCollapsed: isCollapsed } = storeToRefs(appearanceSettingsStore);
const navLayout = ref([]);
const navLayoutReady = ref(false);
const collapsedDropdownOpenId = ref(null);
const menuItems = 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,
title: definition.tooltip || definition.labelKey,
titleIsCustom: false
});
return;
}
if (entry.type === 'folder') {
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
if (folderDefinitions.length === 0) {
return;
}
const folderEntries = folderDefinitions.map((definition) => ({
label: definition.labelKey,
routeName: definition.routeName,
index: definition.key,
icon: definition.icon,
action: definition.action
}));
items.push({
index: entry.id,
icon: entry.icon || DEFAULT_FOLDER_ICON,
title: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
titleIsCustom: true,
children: folderEntries
});
}
});
return items;
});
const activeMenuIndex = computed(() => {
const currentRoute = router.currentRoute.value;
const currentRouteName = currentRoute?.name;
const navKey = currentRoute?.meta?.navKey || currentRouteName;
if (!navKey) {
return getFirstNavRouteLocal(navLayout.value) || 'feed';
}
for (const entry of navLayout.value) {
if (entry.type === 'item' && entry.key === navKey) {
return entry.key;
}
if (entry.type === 'folder' && entry.items?.includes(navKey)) {
return navKey;
}
}
return getFirstNavRouteLocal(navLayout.value) || 'feed';
});
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
const vrcxLogo = new URL('../../images/VRCX.png', import.meta.url).href;
const themes = computed(() => Object.keys(THEME_CONFIG));
const { themeColors, currentThemeColor, isApplyingThemeColor, applyThemeColor, initThemeColor } = useThemeColor();
watch(
() => locale.value,
() => {
if (!navLayoutReady.value) {
return;
}
navLayout.value = navLayout.value.map((entry) => {
if (entry.type === 'folder' && entry.nameKey) {
return {
...entry,
name: t(entry.nameKey)
};
}
return entry;
});
}
);
watch(
() => isCollapsed.value,
(value) => {
if (!value) {
collapsedDropdownOpenId.value = null;
}
}
);
const generateFolderId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `nav-folder-${crypto.randomUUID()}`;
}
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
};
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
return sanitizeLayout(layout, hiddenKeys, navDefinitionMap, navDefinitions, t, generateFolderId);
};
const themeDisplayName = (themeKey) => {
const i18nKey = `view.settings.appearance.appearance.theme_mode_${themeKey}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return THEME_CONFIG[themeKey]?.name ?? themeKey;
};
const themeColorDisplayName = (theme) => {
if (!theme) {
return '';
}
const i18nKey = `view.settings.appearance.theme_color.${theme.key}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return theme.label || theme.key;
};
const handleSettingsClick = () => {
router.push({ name: 'settings' });
};
const handleLogoutClick = () => {
logout();
};
const handleThemeSelect = (theme) => {
appearanceSettingsStore.setThemeMode(theme);
};
const handleThemeToggle = () => {
appearanceSettingsStore.toggleThemeMode();
};
const handleTableDensitySelect = (density) => {
appearanceSettingsStore.setTableDensity(density);
};
const handleThemeColorSelect = async (theme) => {
if (!theme) {
return;
}
await applyThemeColor(theme.key);
};
const openGithub = () => {
openExternalLink('https://github.com/vrcx-team/VRCX');
};
const customNavDialogVisible = ref(false);
const navHiddenKeys = ref([]);
const defaultNavLayout = computed(() => sanitizeLayoutLocal(createDefaultNavLayout(), []));
const saveNavLayout = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
try {
await configRepository.setString(
'VRCX_customNavMenuLayoutList',
JSON.stringify({
layout,
hiddenKeys: normalizedHiddenKeys
})
);
} catch (error) {
console.error('Failed to save custom nav', error);
}
};
const handleOpenCustomNavDialog = () => {
customNavDialogVisible.value = true;
};
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
navLayout.value = sanitized;
navHiddenKeys.value = normalizedHiddenKeys;
await saveNavLayout(sanitized, normalizedHiddenKeys);
customNavDialogVisible.value = false;
};
const loadNavMenuConfig = async () => {
let layoutData = null;
let hiddenKeysData = [];
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;
hiddenKeysData = Array.isArray(parsed.hiddenKeys) ? parsed.hiddenKeys : [];
}
}
} catch (error) {
console.error('Failed to load custom nav', error);
} finally {
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData, navDefinitionMap);
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
const sanitized = sanitizeLayoutLocal(fallbackLayout, normalizedHiddenKeys);
navLayout.value = sanitized;
navHiddenKeys.value = normalizedHiddenKeys;
if (
layoutData?.length &&
(JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout) ||
JSON.stringify(normalizedHiddenKeys) !== JSON.stringify(hiddenKeysData))
) {
await saveNavLayout(sanitized, normalizedHiddenKeys);
}
navLayoutReady.value = true;
navigateToFirstNavEntry();
}
};
const handleSupportLink = (id) => {
const target = links[id];
if (target) {
openExternalLink(target);
}
};
const isNavItemNotified = (item) => {
if (!item) {
return false;
}
if (notifiedMenus.value.includes(item.index)) {
return true;
}
if (item.children?.length) {
return item.children.some((entry) => isEntryNotified(entry));
}
return false;
};
const triggerNavAction = (entry, navIndex = entry?.index) => {
if (!entry) {
return;
}
if (entry.action === 'direct-access') {
directAccessPaste();
return;
}
if (entry.routeName) {
handleRouteChange(entry.routeName, navIndex);
return;
}
if (entry.path) {
router.push(entry.path);
}
};
const handleRouteChange = (routeName, navIndex = routeName) => {
if (!routeName) {
return;
}
router.push({ name: routeName });
};
/**
*
* @param layout
*/
const getFirstNavRouteLocal = (layout) => getFirstNavRoute(layout, navDefinitionMap);
let hasNavigatedToInitialRoute = false;
const navigateToFirstNavEntry = () => {
if (hasNavigatedToInitialRoute) {
return;
}
const firstRoute = getFirstNavRouteLocal(navLayout.value);
if (!firstRoute) {
return;
}
hasNavigatedToInitialRoute = true;
if (router.currentRoute.value?.name !== firstRoute) {
router.push({ name: firstRoute }).catch(() => {});
}
};
const handleSubmenuClick = (entry, index) => {
const navIndex = index || entry?.index;
triggerNavAction(entry, navIndex);
};
const handleCollapsedDropdownOpenChange = (index, value) => {
collapsedDropdownOpenId.value = value ? index : null;
};
const handleCollapsedSubmenuSelect = (event, entry, index) => {
if (event?.preventDefault) {
event.preventDefault();
}
handleSubmenuClick(entry, index);
};
const handleMenuItemClick = (item) => {
triggerNavAction(item, item?.index);
};
const toggleNavCollapse = () => {
appearanceSettingsStore.toggleNavCollapsed();
};
onMounted(async () => {
await initThemeColor();
await loadNavMenuConfig();
});
</script>
<style scoped>
.notify {
position: relative;
}
.notify-dot {
position: absolute;
top: 4px;
right: 0;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translateY(-50%);
}
.notify-dot-not-collapsed {
position: absolute;
top: 4px;
right: 0;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translateY(-50%);
}
@container (max-width: 220px) {
.nav-shortcut-hint {
display: none;
}
}
</style>

View File

@@ -30,6 +30,8 @@
:drag-state="dragState"
@edit-folder="openFolderEditor"
@delete-folder="handleDeleteFolder"
@edit-dashboard="openDashboardEditor"
@delete-dashboard="handleDeleteDashboard"
@hide="handleHideItem"
@toggle="handleTreeToggle(item)" />
</template>
@@ -73,6 +75,9 @@
<Button variant="outline" @click="handleAddFolder">
{{ t('nav_menu.custom_nav.new_folder') }}
</Button>
<Button variant="outline" @click="handleAddDashboard">
{{ t('dashboard.new_dashboard') }}
</Button>
<Button variant="ghost" class="text-destructive" @click="handleReset">
{{ t('nav_menu.custom_nav.restore_default') }}
</Button>
@@ -93,7 +98,13 @@
<Dialog v-model:open="folderEditor.visible">
<DialogContent class="sm:max-w-100">
<DialogHeader>
<DialogTitle>{{ t('nav_menu.custom_nav.edit_folder') }}</DialogTitle>
<DialogTitle>
{{
folderEditor.editorType === 'dashboard'
? t('nav_menu.custom_nav.edit_dashboard')
: t('nav_menu.custom_nav.edit_folder')
}}
</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-3">
<InputGroupField
@@ -159,6 +170,8 @@
import { Separator } from '../ui/separator';
import { Tree } from '../ui/tree';
import { navDefinitions } from '../../shared/constants/ui.js';
import { DASHBOARD_NAV_KEY_PREFIX, DEFAULT_DASHBOARD_ICON } from '../../shared/constants/dashboard';
import { useDashboardStore, useModalStore } from '../../stores';
import SortableTreeNode from './SortableTreeNode.vue';
@@ -178,11 +191,17 @@
defaultLayout: {
type: Array,
default: () => []
},
definitions: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:visible', 'save']);
const emit = defineEmits(['update:visible', 'save', 'dashboard-created']);
const { t } = useI18n();
const dashboardStore = useDashboardStore();
const modalStore = useModalStore();
const cloneLayout = (source) => {
if (!Array.isArray(source)) return [];
@@ -210,6 +229,7 @@
visible: false,
isEditing: false,
editingId: null,
editorType: 'folder',
data: { id: '', name: '', icon: '' }
});
@@ -243,49 +263,53 @@
const definitionsMap = computed(() => {
const map = new Map();
navDefinitions.forEach((def) => {
const source = props.definitions?.length ? props.definitions : navDefinitions;
source.forEach((def) => {
if (def?.key) map.set(def.key, def);
});
return map;
});
const treeItems = computed(() => {
return localLayout.value.map((entry) => {
if (entry.type === 'folder') {
const children = (entry.items || [])
.map((key) => {
const def = definitionsMap.value.get(key);
if (!def) return null;
return { id: key, type: 'item', key, level: 1, parentId: entry.id };
})
.filter(Boolean);
return localLayout.value
.map((entry) => {
if (entry.type === 'folder') {
const children = (entry.items || [])
.map((key) => {
const def = definitionsMap.value.get(key);
if (!def) return null;
return { id: key, type: 'item', key, level: 1, parentId: entry.id };
})
.filter(Boolean);
const folderChildren = children.length
? children
: [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }];
const folderChildren = children.length
? children
: [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }];
return {
id: entry.id,
type: 'folder',
name: entry.name,
icon: entry.icon,
level: 0,
children: folderChildren
};
}
return { id: entry.key, type: 'item', key: entry.key, level: 0 };
});
return {
id: entry.id,
type: 'folder',
name: entry.name,
icon: entry.icon,
level: 0,
children: folderChildren
};
}
if (!definitionsMap.value.has(entry.key)) return null;
return { id: entry.key, type: 'item', key: entry.key, level: 0 };
})
.filter(Boolean);
});
const expandedKeys = ref([]);
const hiddenItems = computed(() =>
navDefinitions
(props.definitions?.length ? props.definitions : navDefinitions)
.filter((def) => hiddenKeySet.value.has(def.key))
.map((def) => ({
key: def.key,
icon: def.icon,
label: t(def.labelKey)
label: def.isDashboard ? def.labelKey : t(def.labelKey)
}))
);
@@ -646,13 +670,31 @@
if (!entry) return;
folderEditor.isEditing = true;
folderEditor.editingId = folderId;
folderEditor.editorType = 'folder';
folderEditor.data = { id: entry.id, name: entry.name, icon: entry.icon };
folderEditor.visible = true;
};
const openDashboardEditor = (dashboardKey) => {
const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, '');
const dashboard = dashboardStore.getDashboard(dashboardId);
if (!dashboard) return;
folderEditor.isEditing = true;
folderEditor.editingId = dashboardKey;
folderEditor.editorType = 'dashboard';
folderEditor.data = {
id: dashboardKey,
name: dashboard.name,
icon: dashboard.icon || ''
};
folderEditor.visible = true;
};
const handleAddFolder = () => {
folderEditor.isEditing = false;
folderEditor.editingId = null;
folderEditor.editorType = 'folder';
folderEditor.data = {
id: createFolderId(),
name: '',
@@ -661,10 +703,27 @@
folderEditor.visible = true;
};
const handleFolderEditorSave = () => {
const handleAddDashboard = async () => {
const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name'));
dashboardStore.setEditingDashboardId(dashboard.id);
localLayout.value.push({
type: 'item',
key: `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}`
});
localLayout.value = [...localLayout.value];
emit('dashboard-created', dashboard.id, cloneLayout(localLayout.value), [...hiddenKeySet.value]);
};
const handleFolderEditorSave = async () => {
if (!folderEditor.data.name?.trim()) return;
if (folderEditor.isEditing) {
if (folderEditor.editorType === 'dashboard') {
const dashboardId = String(folderEditor.editingId || '').replace(DASHBOARD_NAV_KEY_PREFIX, '');
await dashboardStore.updateDashboard(dashboardId, {
name: folderEditor.data.name.trim(),
icon: folderEditor.data.icon?.trim() || DEFAULT_DASHBOARD_ICON
});
} else if (folderEditor.isEditing) {
const entry = localLayout.value.find((e) => e.type === 'folder' && e.id === folderEditor.editingId);
if (entry) {
entry.name = folderEditor.data.name.trim();
@@ -689,6 +748,43 @@
folderEditor.visible = false;
};
const removeFromLayout = (key) => {
let removed = false;
for (let i = 0; i < localLayout.value.length; i++) {
const entry = localLayout.value[i];
if (entry.type === 'item' && entry.key === key) {
localLayout.value.splice(i, 1);
removed = true;
break;
}
if (entry.type === 'folder') {
const idx = entry.items?.indexOf(key);
if (idx !== undefined && idx >= 0) {
entry.items.splice(idx, 1);
removed = true;
}
}
}
if (removed) {
hiddenKeySet.value.delete(key);
hiddenPlacement.value.delete(key);
localLayout.value = [...localLayout.value];
}
};
const handleDeleteDashboard = async (dashboardKey) => {
const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, '');
const { ok } = await modalStore.confirm({
title: t('dashboard.confirmations.delete_title'),
description: t('dashboard.confirmations.delete_description')
});
if (!ok) {
return;
}
await dashboardStore.deleteDashboard(dashboardId);
removeFromLayout(dashboardKey);
};
const handleSave = () => {
const cleanedLayout = localLayout.value.filter(
(entry) => !(entry.type === 'folder' && (!entry.items || entry.items.length === 0))

View File

@@ -19,7 +19,7 @@
dragState: { type: Object, default: () => ({}) }
});
const emit = defineEmits(['editFolder', 'deleteFolder', 'hide', 'toggle']);
const emit = defineEmits(['editFolder', 'deleteFolder', 'editDashboard', 'deleteDashboard', 'hide', 'toggle']);
const { t } = useI18n();
@@ -27,6 +27,9 @@
const nodeValue = computed(() => props.item.value);
const isFolder = computed(() => nodeValue.value?.type === 'folder');
const isDashboard = computed(() => {
return !isFolder.value && nodeValue.value?.key?.startsWith('dashboard-');
});
const hasChildren = computed(() => props.item.hasChildren);
const level = computed(() => nodeValue.value?.level ?? 0);
const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key));
@@ -43,7 +46,10 @@
return nodeValue.value.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder');
}
const def = props.definitionsMap.get(nodeValue.value?.key);
return def ? t(def.labelKey) : nodeValue.value?.key || '';
if (!def) {
return nodeValue.value?.key || '';
}
return def.isDashboard ? def.labelKey : t(def.labelKey);
});
const displayIcon = computed(() => {
@@ -111,6 +117,14 @@
{{ t('nav_menu.custom_nav.delete_folder') }}
</DropdownMenuItem>
</template>
<template v-else-if="isDashboard">
<DropdownMenuItem @click="emit('editDashboard', nodeValue.key)">
{{ t('nav_menu.custom_nav.edit_dashboard') }}
</DropdownMenuItem>
<DropdownMenuItem class="text-destructive" @click="emit('deleteDashboard', nodeValue.key)">
{{ t('nav_menu.custom_nav.delete_dashboard') }}
</DropdownMenuItem>
</template>
<template v-else>
<DropdownMenuItem @click="emit('hide', nodeValue.key)">
{{ t('nav_menu.custom_nav.hide') }}

View File

@@ -3,6 +3,18 @@ import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
vi.mock('@/shared/utils/common', () => ({ openExternalLink: vi.fn() }));
vi.mock('../../../stores', () => ({
useDashboardStore: () => ({
createDashboard: vi.fn(async () => ({ id: 'dashboard-1', name: 'Dashboard', icon: 'ri-dashboard-line' })),
getDashboard: vi.fn(() => ({ id: 'dashboard-1', name: 'Dashboard', icon: 'ri-dashboard-line' })),
updateDashboard: vi.fn(async () => {}),
deleteDashboard: vi.fn(async () => {}),
setEditingDashboardId: vi.fn()
}),
useModalStore: () => ({
confirm: vi.fn(async () => ({ ok: true }))
})
}));
vi.mock('@/components/ui/dialog', () => ({
Dialog: { template: '<div><slot /></div>' },
DialogContent: { template: '<div><slot /></div>' },

View File

@@ -0,0 +1,443 @@
<template>
<Sidebar side="left" variant="sidebar" collapsible="icon">
<SidebarHeader class="px-2 py-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
:tooltip="t('dashboard.new_dashboard')"
class="border border-dashed border-primary/40 text-primary hover:bg-primary/10"
@click="handleQuickCreateDashboard">
<Plus class="size-4" />
<span v-show="!isCollapsed">{{ t('dashboard.new_dashboard') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarContent class="pt-2" style="container-type: inline-size">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu v-if="navLayoutReady">
<template v-for="item in menuItems" :key="item.index">
<SidebarMenuItem v-if="!item.children?.length">
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarMenuButton
:is-active="activeMenuIndex === item.index"
:tooltip="getItemTooltip(item)"
@click="handleMenuItemClick(item)">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative">
<span
v-if="isNavItemNotified(item)"
class="notify-dot-not-collapsed bg-red-500"
:class="{ '-right-1!': isCollapsed }"
aria-hidden="true"></span>
</i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<span
v-if="item.action === 'direct-access' && !isCollapsed"
class="nav-shortcut-hint ml-auto inline-flex items-center gap-0.5">
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
<Kbd>D</Kbd>
</span>
</SidebarMenuButton>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
:disabled="!hasNotifications"
@click="clearAllNotifications">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator />
<template v-if="isDashboardItem(item)">
<ContextMenuItem @click="handleEditDashboard(item)">
{{ t('nav_menu.edit_dashboard') }}
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
@click="handleDeleteDashboard(item)">
{{ t('nav_menu.delete_dashboard') }}
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="handleQuickCreateDashboard">
{{ t('dashboard.new_dashboard') }}
</ContextMenuItem>
<ContextMenuItem @click="handleOpenCustomNavDialog">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</SidebarMenuItem>
<NavMenuFolderItem
v-else
:item="item"
:is-collapsed="isCollapsed"
:active-menu-index="activeMenuIndex"
:collapsed-dropdown-open-id="collapsedDropdownOpenId"
:has-notifications="hasNotifications"
:is-entry-notified="isEntryNotified"
:is-nav-item-notified="isNavItemNotified"
:is-dashboard-item="isDashboardItem"
@collapsed-dropdown-open-change="handleCollapsedDropdownOpenChange"
@collapsed-submenu-select="handleCollapsedSubmenuSelect"
@submenu-click="handleSubmenuClick"
@clear-notifications="clearAllNotifications"
@edit-dashboard="handleEditDashboard"
@delete-dashboard="handleDeleteDashboard"
@create-dashboard="handleQuickCreateDashboard"
@open-custom-nav="handleOpenCustomNavDialog" />
</template>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem :disabled="!hasNotifications" @click="clearAllNotifications">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="handleQuickCreateDashboard">
{{ t('dashboard.new_dashboard') }}
</ContextMenuItem>
<ContextMenuItem @click="handleOpenCustomNavDialog">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<NavMenuFooter
:is-collapsed="isCollapsed"
:is-dark-mode="isDarkMode"
:has-pending-update="pendingVRCXUpdate"
:has-pending-install="!!pendingVRCXInstall"
:version="version"
:vrcx-logo="vrcxLogo"
:themes="themes"
:theme-mode="themeMode"
:table-density="tableDensity"
:theme-colors="themeColors"
:current-theme-color="currentThemeColor"
:is-applying-theme-color="isApplyingThemeColor"
:theme-display-name="themeDisplayName"
:theme-color-display-name="themeColorDisplayName"
@show-change-log="showChangeLogDialog"
@support-link="handleSupportLink"
@toggle-theme="handleThemeToggle"
@show-vrcx-update-dialog="showVRCXUpdateDialog"
@settings-click="handleSettingsClick"
@theme-select="handleThemeSelect"
@theme-color-select="handleThemeColorSelect"
@table-density-select="handleTableDensitySelect"
@open-custom-nav="handleOpenCustomNavDialog"
@logout-click="handleLogoutClick"
@toggle-nav-collapse="toggleNavCollapse"
@open-github="openGithub" />
</Sidebar>
<CustomNavDialog
v-model:visible="customNavDialogVisible"
:layout="navLayout"
:hidden-keys="navHiddenKeys"
:default-layout="defaultNavLayout"
:definitions="allNavDefinitions"
@save="handleCustomNavSave"
@dashboard-created="handleDashboardCreated" />
</template>
<script setup>
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { Plus } from 'lucide-vue-next';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useNavLayout } from './composables/useNavLayout';
import { useNavTheme } from './composables/useNavTheme';
import { Kbd } from '@/components/ui/kbd';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar';
import {
useAppearanceSettingsStore,
useAuthStore,
useDashboardStore,
useModalStore,
useSearchStore,
useUiStore,
useVRCXUpdaterStore
} from '../../stores';
import { isEntryNotified as checkEntryNotified } from './navMenuUtils';
import { DASHBOARD_NAV_KEY_PREFIX, links } from '../../shared/constants';
import { openExternalLink } from '../../shared/utils';
import NavMenuFolderItem from './NavMenuFolderItem.vue';
import NavMenuFooter from './NavMenuFooter.vue';
const CustomNavDialog = defineAsyncComponent(() => import('../dialogs/CustomNavDialog.vue'));
const { t, locale } = useI18n();
const router = useRouter();
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const VRCXUpdaterStore = useVRCXUpdaterStore();
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
const dashboardStore = useDashboardStore();
const { dashboards } = storeToRefs(dashboardStore);
const uiStore = useUiStore();
const { notifiedMenus } = storeToRefs(uiStore);
const { clearAllNotifications } = uiStore;
const { directAccessPaste } = useSearchStore();
const { logout } = useAuthStore();
const modalStore = useModalStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
const { themeMode, tableDensity, isDarkMode, isNavCollapsed: isCollapsed } = storeToRefs(appearanceSettingsStore);
const {
themes,
themeColors,
currentThemeColor,
isApplyingThemeColor,
initThemeColor,
themeDisplayName,
themeColorDisplayName,
handleThemeSelect,
handleThemeToggle,
handleTableDensitySelect,
handleThemeColorSelect
} = useNavTheme({
t,
appearanceSettingsStore
});
const {
navLayout,
navLayoutReady,
navHiddenKeys,
menuItems,
activeMenuIndex,
allNavDefinitions,
defaultNavLayout,
sanitizeLayoutLocal,
saveNavLayout,
applyCustomNavLayout,
loadNavMenuConfig,
triggerNavAction
} = useNavLayout({
t,
locale,
router,
dashboardStore,
dashboards,
directAccessPaste
});
const collapsedDropdownOpenId = ref(null);
const customNavDialogVisible = ref(false);
const hasNotifications = computed(() => notifiedMenus.value.length > 0);
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
const vrcxLogo = new URL('../../../images/VRCX.png', import.meta.url).href;
const isEntryNotified = (entry) => checkEntryNotified(entry, notifiedMenus.value);
const getItemTooltip = (item) => {
const label = item.titleIsCustom ? item.title : t(item.title || '');
if (item.action !== 'direct-access') {
return label;
}
return () =>
h('span', { class: 'inline-flex items-center gap-1' }, [
label,
h(Kbd, () => (isMac ? '⌘' : 'Ctrl')),
h(Kbd, () => 'D')
]);
};
const isNavItemNotified = (item) => {
if (!item) {
return false;
}
if (notifiedMenus.value.includes(item.index)) {
return true;
}
if (item.children?.length) {
return item.children.some((entry) => isEntryNotified(entry));
}
return false;
};
const handleSettingsClick = () => {
router.push({ name: 'settings' });
};
const handleLogoutClick = () => {
logout();
};
const openGithub = () => {
openExternalLink('https://github.com/vrcx-team/VRCX');
};
const handleSupportLink = (id) => {
const target = links[id];
if (target) {
openExternalLink(target);
}
};
const handleOpenCustomNavDialog = () => {
customNavDialogVisible.value = true;
};
const isDashboardItem = (item) => item?.index?.startsWith(DASHBOARD_NAV_KEY_PREFIX);
const handleQuickCreateDashboard = async () => {
const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name'));
const dashboardKey = `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}`;
const currentLayout = [...navLayout.value];
const directAccessIdx = currentLayout.findIndex(
(entry) => entry.type === 'item' && entry.key === 'direct-access'
);
const newEntry = { type: 'item', key: dashboardKey };
if (directAccessIdx !== -1) {
currentLayout.splice(directAccessIdx, 0, newEntry);
} else {
currentLayout.push(newEntry);
}
const nextLayout = currentLayout;
const nextHiddenKeys = navHiddenKeys.value.filter((key) => key !== dashboardKey);
const sanitized = sanitizeLayoutLocal(nextLayout, nextHiddenKeys);
navLayout.value = sanitized;
navHiddenKeys.value = nextHiddenKeys;
await saveNavLayout(sanitized, nextHiddenKeys);
dashboardStore.setEditingDashboardId(dashboard.id);
router.push({ name: 'dashboard', params: { id: dashboard.id } });
};
const handleEditDashboard = (item) => {
if (!isDashboardItem(item)) {
return;
}
const dashboardId = item.index.replace(DASHBOARD_NAV_KEY_PREFIX, '');
dashboardStore.setEditingDashboardId(dashboardId);
const currentRoute = router.currentRoute.value;
if (currentRoute?.name !== 'dashboard' || String(currentRoute?.params?.id || '') !== dashboardId) {
router.push({ name: 'dashboard', params: { id: dashboardId } });
}
};
const handleDeleteDashboard = async (item) => {
if (!isDashboardItem(item)) {
return;
}
const { ok } = await modalStore.confirm({
title: t('dashboard.confirmations.delete_title'),
description: t('dashboard.confirmations.delete_description')
});
if (!ok) {
return;
}
const dashboardId = item.index.replace(DASHBOARD_NAV_KEY_PREFIX, '');
await dashboardStore.deleteDashboard(dashboardId);
const currentRoute = router.currentRoute.value;
if (currentRoute?.name === 'dashboard' && String(currentRoute?.params?.id || '') === dashboardId) {
router.replace({ name: 'feed' });
}
};
const handleCustomNavSave = async (layout, hiddenKeys) => {
await applyCustomNavLayout(layout, hiddenKeys);
customNavDialogVisible.value = false;
};
const handleDashboardCreated = async (dashboardId, layout, hiddenKeys) => {
await handleCustomNavSave(layout, hiddenKeys);
router.push({ name: 'dashboard', params: { id: dashboardId } });
};
const handleSubmenuClick = (entry) => {
triggerNavAction(entry);
};
const handleCollapsedDropdownOpenChange = (index, value) => {
collapsedDropdownOpenId.value = value ? index : null;
};
const handleCollapsedSubmenuSelect = (event, entry) => {
if (event?.preventDefault) {
event.preventDefault();
}
handleSubmenuClick(entry);
};
const handleMenuItemClick = (item) => {
triggerNavAction(item);
};
const toggleNavCollapse = () => {
appearanceSettingsStore.toggleNavCollapsed();
};
watch(
() => isCollapsed.value,
(value) => {
if (!value) {
collapsedDropdownOpenId.value = null;
}
}
);
onMounted(async () => {
await initThemeColor();
await dashboardStore.loadDashboards();
await loadNavMenuConfig();
});
</script>
<style scoped>
.notify-dot-not-collapsed {
position: absolute;
top: 4px;
right: 0;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translateY(-50%);
}
@container (max-width: 220px) {
.nav-shortcut-hint {
display: none;
}
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<SidebarMenuItem>
<ContextMenu>
<ContextMenuTrigger as-child>
<div class="w-full">
<DropdownMenu
v-if="isCollapsed"
:open="collapsedDropdownOpenId === item.index"
@update:open="(value) => emit('collapsed-dropdown-open-change', item.index, value)">
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isNavItemNotified(item)"
class="notify-dot bg-red-500 -right-1!"
aria-hidden="true"></span
></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem
v-for="entry in item.children"
:key="entry.index"
@select="(event) => emit('collapsed-submenu-select', event, entry)">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-4 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot bg-red-500 -right-1! top-0.5!"
aria-hidden="true"></span
></i>
<span v-if="entry.titleIsCustom">{{ entry.label }}</span>
<span v-else>{{ t(entry.label) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Collapsible
v-else
class="group/collapsible"
:default-open="activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex)">
<template #default="{ open }">
<CollapsibleTrigger as-child>
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isNavItemNotified(item)"
class="notify-dot bg-red-500"
aria-hidden="true"></span
></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<ChevronRight
v-show="!isCollapsed"
class="ml-auto transition-transform"
:class="open ? 'rotate-90' : ''" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index">
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarMenuSubButton
:is-active="activeMenuIndex === entry.index"
@click="emit('submenu-click', entry)">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-5 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot bg-red-500 -right-0.5!"
aria-hidden="true"></span
></i>
<span v-if="entry.titleIsCustom">{{ entry.label }}</span>
<span v-else>{{ t(entry.label) }}</span>
</SidebarMenuSubButton>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
:disabled="!hasNotifications"
@click="emit('clear-notifications')">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator />
<template v-if="isDashboardItem(entry)">
<ContextMenuItem @click="emit('edit-dashboard', entry)">
{{ t('nav_menu.edit_dashboard') }}
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
@click="emit('delete-dashboard', entry)">
{{ t('nav_menu.delete_dashboard') }}
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="emit('create-dashboard')">
{{ t('dashboard.new_dashboard') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('open-custom-nav')">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</template>
</Collapsible>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem :disabled="!hasNotifications" @click="emit('clear-notifications')">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="emit('create-dashboard')">
{{ t('dashboard.new_dashboard') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('open-custom-nav')">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</SidebarMenuItem>
</template>
<script setup>
import { ChevronRight } from 'lucide-vue-next';
import { useI18n } from 'vue-i18n';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem
} from '@/components/ui/sidebar';
defineProps({
item: {
type: Object,
required: true
},
isCollapsed: {
type: Boolean,
default: false
},
activeMenuIndex: {
type: String,
default: ''
},
collapsedDropdownOpenId: {
type: String,
default: null
},
hasNotifications: {
type: Boolean,
default: false
},
isEntryNotified: {
type: Function,
required: true
},
isNavItemNotified: {
type: Function,
required: true
},
isDashboardItem: {
type: Function,
required: true
}
});
const emit = defineEmits([
'collapsed-dropdown-open-change',
'collapsed-submenu-select',
'submenu-click',
'clear-notifications',
'edit-dashboard',
'delete-dashboard',
'create-dashboard',
'open-custom-nav'
]);
const { t } = useI18n();
</script>
<style scoped>
.notify-dot {
position: absolute;
top: 4px;
right: 0;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translateY(-50%);
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<SidebarFooter class="px-2 py-3">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="t('nav_tooltip.help_support')">
<i class="ri-question-line inline-flex size-6 items-center justify-center text-lg" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.help_support') }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem @click="emit('show-change-log')">
<span>{{ t('nav_menu.whats_new') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t('nav_menu.resources') }}</DropdownMenuLabel>
<DropdownMenuItem @click="emit('support-link', 'wiki')">
<span>{{ t('nav_menu.wiki') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t('nav_menu.get_help') }}</DropdownMenuLabel>
<DropdownMenuItem @click="emit('support-link', 'github')">
<span>{{ t('nav_menu.github') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="emit('support-link', 'discord')">
<span>{{ t('nav_menu.discord') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton :tooltip="t('nav_tooltip.toggle_theme')" @click="emit('toggle-theme')">
<i
:class="isDarkMode ? 'ri-moon-line' : 'ri-sun-line'"
class="inline-flex size-6 items-center justify-center text-[19px]" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.toggle_theme') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="t('nav_tooltip.manage')">
<span class="relative inline-flex size-6 items-center justify-center">
<i class="ri-settings-3-line text-lg" />
<span
v-if="hasPendingUpdate || hasPendingInstall"
class="absolute top-0.5 -right-1 h-1.5 w-1.5 rounded-full bg-red-500"></span>
</span>
<span v-show="!isCollapsed">{{ t('nav_tooltip.manage') }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-54">
<div class="flex items-center gap-2 px-2 py-1.5">
<img
class="h-6 w-6 cursor-pointer"
:src="vrcxLogo"
alt="VRCX"
@click="emit('open-github')" />
<div class="flex min-w-0 flex-col">
<button
type="button"
class="text-left text-sm font-medium truncate flex items-center gap-1"
@click="emit('open-github')">
VRCX
<Heart class="text-primary fill-current stroke-none" />
</button>
<span class="text-xs text-muted-foreground">{{ version }}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="hasPendingUpdate || hasPendingInstall"
@click="emit('show-vrcx-update-dialog')">
<span>{{ t('nav_menu.update_available') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPendingUpdate || hasPendingInstall" />
<DropdownMenuItem @click="emit('settings-click')">
<span>{{ t('nav_tooltip.settings') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.settings.appearance.appearance.theme_mode') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" align="start" class="w-54">
<DropdownMenuCheckboxItem
v-for="theme in themes"
:key="theme"
:model-value="themeMode === theme"
indicator-position="right"
@select="emit('theme-select', theme)">
<span>{{ themeDisplayName(theme) }}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel class="px-2 py-2 font-normal">
<div class="flex items-center justify-around">
<TooltipWrapper
v-for="theme in themeColors"
:key="theme.key"
side="top"
:content="themeColorDisplayName(theme)"
:delay-duration="600">
<button
type="button"
:disabled="isApplyingThemeColor"
:aria-pressed="currentThemeColor === theme.key"
:aria-label="themeColorDisplayName(theme)"
:title="themeColorDisplayName(theme)"
@click="emit('theme-color-select', theme)"
class="h-3.5 w-3.5 shrink-0 rounded-sm transition-transform hover:scale-125"
:class="
currentThemeColor === theme.key
? 'ring-1 ring-ring ring-offset-1 ring-offset-background'
: ''
"
:style="{ backgroundColor: theme.swatch }"></button>
</TooltipWrapper>
</div>
</DropdownMenuLabel>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.settings.appearance.appearance.table_density') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" align="start" class="w-54">
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'standard'"
indicator-position="right"
@select="emit('table-density-select', 'standard')">
<span>{{
t('view.settings.appearance.appearance.table_density_comfortable')
}}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'comfortable'"
indicator-position="right"
@select="emit('table-density-select', 'comfortable')">
<span>{{ t('view.settings.appearance.appearance.table_density_standard') }}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'compact'"
indicator-position="right"
@select="emit('table-density-select', 'compact')">
<span>{{ t('view.settings.appearance.appearance.table_density_compact') }}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="emit('open-custom-nav')">
<span>{{ t('nav_menu.custom_nav.header') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" @click="emit('logout-click')">
<span>{{ t('dialog.user.actions.logout') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
:tooltip="isCollapsed ? t('nav_tooltip.expand_menu') : t('nav_tooltip.collapse_menu')"
@click="emit('toggle-nav-collapse')">
<i class="ri-side-bar-line inline-flex size-6 items-center justify-center text-[19px]" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.collapse_menu') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</template>
<script setup>
import { Heart } from 'lucide-vue-next';
import { useI18n } from 'vue-i18n';
import { TooltipWrapper } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { SidebarFooter, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
defineProps({
isCollapsed: {
type: Boolean,
default: false
},
isDarkMode: {
type: Boolean,
default: false
},
hasPendingUpdate: {
type: Boolean,
default: false
},
hasPendingInstall: {
type: Boolean,
default: false
},
version: {
type: String,
default: '-'
},
vrcxLogo: {
type: String,
required: true
},
themes: {
type: Array,
default: () => []
},
themeMode: {
type: String,
default: 'system'
},
tableDensity: {
type: String,
default: 'standard'
},
themeColors: {
type: Array,
default: () => []
},
currentThemeColor: {
type: String,
default: ''
},
isApplyingThemeColor: {
type: Boolean,
default: false
},
themeDisplayName: {
type: Function,
required: true
},
themeColorDisplayName: {
type: Function,
required: true
}
});
const emit = defineEmits([
'show-change-log',
'support-link',
'toggle-theme',
'show-vrcx-update-dialog',
'settings-click',
'theme-select',
'theme-color-select',
'table-density-select',
'open-custom-nav',
'logout-click',
'toggle-nav-collapse',
'open-github'
]);
const { t } = useI18n();
</script>

View File

@@ -23,7 +23,13 @@ const mocks = vi.hoisted(() => ({
tableDensity: { value: 'standard' },
isDarkMode: { value: false },
isNavCollapsed: { value: false },
currentRoute: { value: { name: 'unknown', meta: {} } }
currentRoute: { value: { name: 'unknown', meta: {} } },
dashboards: { value: [] },
dashboardNavKeys: new Set(),
loadDashboards: vi.fn(() => Promise.resolve()),
getDashboardNavDefinitions: vi.fn(() => []),
createDashboard: vi.fn(() => Promise.resolve({ id: 'dashboard-1' })),
setEditingDashboardId: vi.fn()
}));
vi.mock('pinia', async (importOriginal) => {
@@ -41,15 +47,15 @@ vi.mock('vue-i18n', () => ({
})
}));
vi.mock('../../views/Feed/Feed.vue', () => ({
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../views/Feed/columns.jsx', () => ({
vi.mock('../../../views/Feed/columns.jsx', () => ({
columns: []
}));
vi.mock('../../plugins/router', () => ({
vi.mock('../../../plugins/router', () => ({
router: {
beforeEach: vi.fn(),
push: vi.fn(),
@@ -60,11 +66,11 @@ vi.mock('../../plugins/router', () => ({
initRouter: vi.fn()
}));
vi.mock('../../plugins/interopApi', () => ({
vi.mock('../../../plugins/interopApi', () => ({
initInteropApi: vi.fn()
}));
vi.mock('../../services/database', () => ({
vi.mock('../../../services/database', () => ({
database: new Proxy(
{},
{
@@ -76,11 +82,11 @@ vi.mock('../../services/database', () => ({
)
}));
vi.mock('../../services/jsonStorage', () => ({
vi.mock('../../../services/jsonStorage', () => ({
default: vi.fn()
}));
vi.mock('../../services/watchState', () => ({
vi.mock('../../../services/watchState', () => ({
watchState: { isLoggedIn: false }
}));
@@ -91,7 +97,7 @@ vi.mock('vue-router', () => ({
})
}));
vi.mock('../../stores', () => ({
vi.mock('../../../stores', () => ({
useVRCXUpdaterStore: () => ({
pendingVRCXUpdate: mocks.pendingVRCXUpdate,
pendingVRCXInstall: mocks.pendingVRCXInstall,
@@ -118,17 +124,30 @@ vi.mock('../../stores', () => ({
toggleThemeMode: (...args) => mocks.toggleThemeMode(...args),
setTableDensity: vi.fn(),
toggleNavCollapsed: (...args) => mocks.toggleNavCollapsed(...args)
}),
useDashboardStore: () => ({
dashboards: mocks.dashboards,
dashboardNavKeys: mocks.dashboardNavKeys,
loadDashboards: (...args) => mocks.loadDashboards(...args),
getDashboardNavDefinitions: (...args) =>
mocks.getDashboardNavDefinitions(...args),
createDashboard: (...args) => mocks.createDashboard(...args),
setEditingDashboardId: (...args) => mocks.setEditingDashboardId(...args)
}),
useModalStore: () => ({
confirm: vi.fn(() => Promise.resolve({ ok: false }))
})
}));
vi.mock('../../services/config', () => ({
vi.mock('../../../services/config', () => ({
default: {
getString: (...args) => mocks.getString(...args),
setString: (...args) => mocks.setString(...args)
}
}));
vi.mock('../../shared/constants', () => ({
vi.mock('../../../shared/constants', () => ({
DASHBOARD_NAV_KEY_PREFIX: 'dashboard-',
THEME_CONFIG: {
system: { name: 'System' },
light: { name: 'Light' },
@@ -155,20 +174,22 @@ vi.mock('../../shared/constants', () => ({
]
}));
vi.mock('./navMenuUtils', () => ({
vi.mock('../navMenuUtils', () => ({
getFirstNavRoute: () => 'feed',
isEntryNotified: () => false,
normalizeHiddenKeys: (keys) => keys || [],
sanitizeLayout: (layout) => layout
}));
vi.mock('../../shared/utils', () => ({
vi.mock('../../../shared/utils', () => ({
openExternalLink: (...args) => mocks.openExternalLink(...args)
}));
vi.mock('@/shared/utils/base/ui', () => ({
useThemeColor: () => ({
themeColors: { value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }] },
themeColors: {
value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }]
},
currentThemeColor: { value: 'blue' },
isApplyingThemeColor: { value: false },
applyThemeColor: (...args) => mocks.applyThemeColor(...args),
@@ -178,6 +199,7 @@ vi.mock('@/shared/utils/base/ui', () => ({
vi.mock('@/components/ui/sidebar', () => ({
Sidebar: { template: '<div><slot /></div>' },
SidebarHeader: { template: '<div><slot /></div>' },
SidebarContent: { template: '<div><slot /></div>' },
SidebarFooter: { template: '<div><slot /></div>' },
SidebarGroup: { template: '<div><slot /></div>' },
@@ -188,11 +210,13 @@ vi.mock('@/components/ui/sidebar', () => ({
SidebarMenuSubItem: { template: '<div><slot /></div>' },
SidebarMenuButton: {
emits: ['click'],
template: '<button data-testid="menu-btn" @click="$emit(\'click\', $event)"><slot /></button>'
template:
'<button data-testid="menu-btn" @click="$emit(\'click\', $event)"><slot /></button>'
},
SidebarMenuSubButton: {
emits: ['click'],
template: '<button data-testid="submenu-btn" @click="$emit(\'click\', $event)"><slot /></button>'
template:
'<button data-testid="submenu-btn" @click="$emit(\'click\', $event)"><slot /></button>'
}
}));
@@ -200,20 +224,32 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { emits: ['click', 'select'], template: '<button data-testid="dd-item" @click="$emit(\'click\')" @mousedown="$emit(\'select\', $event)"><slot /></button>' },
DropdownMenuItem: {
emits: ['click', 'select'],
template:
'<button data-testid="dd-item" @click="$emit(\'click\')" @mousedown="$emit(\'select\', $event)"><slot /></button>'
},
DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuSub: { template: '<div><slot /></div>' },
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
DropdownMenuSubContent: { template: '<div><slot /></div>' },
DropdownMenuCheckboxItem: { emits: ['select'], template: '<button data-testid="dd-check" @click="$emit(\'select\')"><slot /></button>' }
DropdownMenuCheckboxItem: {
emits: ['select'],
template:
'<button data-testid="dd-check" @click="$emit(\'select\')"><slot /></button>'
}
}));
vi.mock('@/components/ui/context-menu', () => ({
ContextMenu: { template: '<div><slot /></div>' },
ContextMenuTrigger: { template: '<div><slot /></div>' },
ContextMenuContent: { template: '<div><slot /></div>' },
ContextMenuItem: { emits: ['click'], template: '<button data-testid="ctx-item" @click="$emit(\'click\')"><slot /></button>' },
ContextMenuItem: {
emits: ['click'],
template:
'<button data-testid="ctx-item" @click="$emit(\'click\')"><slot /></button>'
},
ContextMenuSeparator: { template: '<hr />' }
}));
@@ -233,7 +269,8 @@ vi.mock('@/components/ui/tooltip', () => ({
vi.mock('lucide-vue-next', () => ({
ChevronRight: { template: '<i />' },
Heart: { template: '<i />' }
Heart: { template: '<i />' },
Plus: { template: '<i />' }
}));
import NavMenu from '../NavMenu.vue';
@@ -242,7 +279,9 @@ function mountComponent() {
return mount(NavMenu, {
global: {
stubs: {
CustomNavDialog: { template: '<div data-testid="custom-nav-dialog" />' }
CustomNavDialog: {
template: '<div data-testid="custom-nav-dialog" />'
}
}
}
});
@@ -261,18 +300,25 @@ describe('NavMenu.vue', () => {
mocks.openExternalLink.mockClear();
mocks.getString.mockClear();
mocks.setString.mockClear();
mocks.loadDashboards.mockClear();
mocks.getDashboardNavDefinitions.mockClear();
mocks.currentRoute.value = { name: 'unknown', meta: {} };
});
it('initializes theme and navigates to first route on mount', async () => {
mountComponent();
await Promise.resolve();
await Promise.resolve();
await vi.waitFor(() => {
expect(mocks.initThemeColor).toHaveBeenCalled();
expect(mocks.loadDashboards).toHaveBeenCalled();
expect(mocks.getString).toHaveBeenCalledWith(
'VRCX_customNavMenuLayoutList'
);
});
expect(mocks.initThemeColor).toHaveBeenCalled();
expect(mocks.getString).toHaveBeenCalledWith('VRCX_customNavMenuLayoutList');
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' });
await vi.waitFor(() => {
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' });
});
});
it('runs direct access action when direct-access menu is clicked', async () => {
@@ -280,7 +326,9 @@ describe('NavMenu.vue', () => {
await vi.waitFor(() => {
const target = wrapper
.findAll('[data-testid="menu-btn"]')
.find((node) => node.text().includes('nav_tooltip.direct_access'));
.find((node) =>
node.text().includes('nav_tooltip.direct_access')
);
expect(target).toBeTruthy();
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('lucide-vue-next', () => ({
ChevronRight: { template: '<i />' }
}));
vi.mock('@/components/ui/sidebar', () => ({
SidebarMenuItem: { template: '<div><slot /></div>' },
SidebarMenuButton: { template: '<button data-testid="folder-btn"><slot /></button>' },
SidebarMenuSub: { template: '<div><slot /></div>' },
SidebarMenuSubItem: { template: '<div><slot /></div>' },
SidebarMenuSubButton: {
emits: ['click'],
template: '<button data-testid="submenu-btn" @click="$emit(\'click\')"><slot /></button>'
}
}));
vi.mock('@/components/ui/collapsible', () => ({
Collapsible: { template: '<div><slot :open="true" /></div>' },
CollapsibleTrigger: { template: '<div><slot /></div>' },
CollapsibleContent: { template: '<div><slot /></div>' }
}));
vi.mock('@/components/ui/context-menu', () => ({
ContextMenu: { template: '<div><slot /></div>' },
ContextMenuTrigger: { template: '<div><slot /></div>' },
ContextMenuContent: { template: '<div><slot /></div>' },
ContextMenuItem: { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' },
ContextMenuSeparator: { template: '<div />' }
}));
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: {
emits: ['update:open'],
template: '<div><button data-testid="dropdown-open" @click="$emit(\'update:open\', true)" /><slot /></div>'
},
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { emits: ['select'], template: '<button @click="$emit(\'select\', $event)"><slot /></button>' }
}));
import NavMenuFolderItem from '../NavMenuFolderItem.vue';
const folderItem = {
index: 'group-1',
icon: 'ri-folder-line',
title: 'Folder',
titleIsCustom: true,
children: [{ index: 'feed', label: 'nav_tooltip.feed', icon: 'ri-rss-line', titleIsCustom: false }]
};
describe('NavMenuFolderItem', () => {
it('emits submenu-click in expanded mode', async () => {
const wrapper = mount(NavMenuFolderItem, {
props: {
item: folderItem,
isCollapsed: false,
activeMenuIndex: '',
collapsedDropdownOpenId: null,
hasNotifications: false,
isEntryNotified: () => false,
isNavItemNotified: () => false,
isDashboardItem: () => false
}
});
await wrapper.find('[data-testid="submenu-btn"]').trigger('click');
expect(wrapper.emitted('submenu-click')).toBeTruthy();
});
it('emits collapsed-dropdown-open-change in collapsed mode', async () => {
const wrapper = mount(NavMenuFolderItem, {
props: {
item: folderItem,
isCollapsed: true,
activeMenuIndex: '',
collapsedDropdownOpenId: null,
hasNotifications: false,
isEntryNotified: () => false,
isNavItemNotified: () => false,
isDashboardItem: () => false
}
});
await wrapper.find('[data-testid="dropdown-open"]').trigger('click');
expect(wrapper.emitted('collapsed-dropdown-open-change')).toBeTruthy();
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('lucide-vue-next', () => ({
Heart: { template: '<i />' }
}));
vi.mock('@/components/ui/tooltip', () => ({
TooltipWrapper: { template: '<span><slot /></span>' }
}));
vi.mock('@/components/ui/sidebar', () => ({
SidebarFooter: { template: '<div><slot /></div>' },
SidebarMenu: { template: '<div><slot /></div>' },
SidebarMenuItem: { template: '<div><slot /></div>' },
SidebarMenuButton: {
emits: ['click'],
template: '<button data-testid="sidebar-menu-btn" @click="$emit(\'click\')"><slot /></button>'
}
}));
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: {
emits: ['click', 'select'],
template: '<button data-testid="dd-item" @click="$emit(\'click\')" @mousedown="$emit(\'select\')"><slot /></button>'
},
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<div />' },
DropdownMenuSub: { template: '<div><slot /></div>' },
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
DropdownMenuSubContent: { template: '<div><slot /></div>' },
DropdownMenuCheckboxItem: {
emits: ['select'],
template: '<button data-testid="dd-check" @click="$emit(\'select\')"><slot /></button>'
}
}));
import NavMenuFooter from '../NavMenuFooter.vue';
const baseProps = {
isCollapsed: false,
isDarkMode: false,
hasPendingUpdate: false,
hasPendingInstall: false,
version: '2026.01.01',
vrcxLogo: 'logo.png',
themes: ['system'],
themeMode: 'system',
tableDensity: 'standard',
themeColors: [{ key: 'blue', label: 'Blue', swatch: '#00f' }],
currentThemeColor: 'blue',
isApplyingThemeColor: false,
themeDisplayName: (value) => value,
themeColorDisplayName: (value) => value?.key || ''
};
describe('NavMenuFooter', () => {
it('renders version and emits toggle-theme click', async () => {
const wrapper = mount(NavMenuFooter, { props: baseProps });
expect(wrapper.text()).toContain('2026.01.01');
const buttons = wrapper.findAll('[data-testid="sidebar-menu-btn"]');
await buttons[1].trigger('click');
expect(wrapper.emitted('toggle-theme')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import { nextTick, ref } from 'vue';
const mocks = vi.hoisted(() => ({
setString: vi.fn(() => Promise.resolve()),
getString: vi.fn(() => Promise.resolve(null))
}));
vi.mock('../../../../services/config', () => ({
default: {
setString: mocks.setString,
getString: mocks.getString
}
}));
vi.mock('../../navMenuUtils', () => ({
normalizeHiddenKeys: (keys) => keys || [],
sanitizeLayout: (layout) => layout
}));
import { useNavLayout } from '../useNavLayout';
describe('useNavLayout', () => {
const createDeps = () => {
const push = vi.fn();
const router = {
push,
currentRoute: ref({ name: 'unknown', meta: {} })
};
const dashboardStore = {
getDashboardNavDefinitions: () => [],
dashboardNavKeys: new Set()
};
return {
router,
push,
dashboardStore,
dashboards: ref([]),
locale: ref('en'),
directAccessPaste: vi.fn()
};
};
it('triggers direct access action', () => {
const deps = createDeps();
const { triggerNavAction } = useNavLayout({
t: (key) => key,
locale: deps.locale,
router: deps.router,
dashboardStore: deps.dashboardStore,
dashboards: deps.dashboards,
directAccessPaste: deps.directAccessPaste
});
triggerNavAction({ action: 'direct-access' });
expect(deps.directAccessPaste).toHaveBeenCalledTimes(1);
});
it('navigates with route name and params', () => {
const deps = createDeps();
const { triggerNavAction } = useNavLayout({
t: (key) => key,
locale: deps.locale,
router: deps.router,
dashboardStore: deps.dashboardStore,
dashboards: deps.dashboards,
directAccessPaste: deps.directAccessPaste
});
triggerNavAction({ routeName: 'dashboard', routeParams: { id: '1' } });
expect(deps.push).toHaveBeenCalledWith({ name: 'dashboard', params: { id: '1' } });
});
it('applies custom layout and persists', async () => {
const deps = createDeps();
const { applyCustomNavLayout, navLayout } = useNavLayout({
t: (key) => key,
locale: deps.locale,
router: deps.router,
dashboardStore: deps.dashboardStore,
dashboards: deps.dashboards,
directAccessPaste: deps.directAccessPaste
});
const layout = [{ type: 'item', key: 'feed' }];
await applyCustomNavLayout(layout, []);
await nextTick();
expect(navLayout.value).toEqual(layout);
expect(mocks.setString).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
const applyThemeColor = vi.fn(() => Promise.resolve());
const initThemeColor = vi.fn(() => Promise.resolve());
vi.mock('@/shared/utils/base/ui', () => ({
useThemeColor: () => ({
themeColors: ref([{ key: 'blue', label: 'Blue', swatch: '#00f' }]),
currentThemeColor: ref('blue'),
isApplyingThemeColor: ref(false),
applyThemeColor,
initThemeColor
})
}));
import { useNavTheme } from '../useNavTheme';
describe('useNavTheme', () => {
it('updates theme mode and table density through appearance store', () => {
const setThemeMode = vi.fn();
const setTableDensity = vi.fn();
const toggleThemeMode = vi.fn();
const { handleThemeSelect, handleTableDensitySelect, handleThemeToggle } = useNavTheme({
t: (key) => key,
appearanceSettingsStore: {
setThemeMode,
setTableDensity,
toggleThemeMode
}
});
handleThemeSelect('dark');
handleTableDensitySelect('compact');
handleThemeToggle();
expect(setThemeMode).toHaveBeenCalledWith('dark');
expect(setTableDensity).toHaveBeenCalledWith('compact');
expect(toggleThemeMode).toHaveBeenCalledTimes(1);
});
it('forwards theme color apply request', async () => {
const { handleThemeColorSelect } = useNavTheme({
t: (key) => key,
appearanceSettingsStore: {
setThemeMode: vi.fn(),
setTableDensity: vi.fn(),
toggleThemeMode: vi.fn()
}
});
await handleThemeColorSelect({ key: 'blue' });
expect(applyThemeColor).toHaveBeenCalledWith('blue');
});
});

View File

@@ -0,0 +1,466 @@
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import configRepository from '../../../services/config';
import {
DASHBOARD_NAV_KEY_PREFIX,
navDefinitions
} from '../../../shared/constants';
import { normalizeHiddenKeys, sanitizeLayout } from '../navMenuUtils';
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
export function useNavLayout({
t,
locale,
router,
dashboardStore,
dashboards,
directAccessPaste
}) {
const navLayout = ref([]);
const navLayoutReady = ref(false);
const navHiddenKeys = ref([]);
const allNavDefinitions = computed(() => [
...navDefinitions,
...dashboardStore.getDashboardNavDefinitions()
]);
const navDefinitionMap = computed(() => {
const map = new Map();
allNavDefinitions.value.forEach((item) => {
map.set(item.key, item);
});
return map;
});
const createDefaultNavLayout = () => [
{ 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',
nameKey: 'nav_tooltip.favorites',
name: t('nav_tooltip.favorites'),
icon: 'ri-star-line',
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
},
{
type: 'folder',
id: 'default-folder-social',
nameKey: 'nav_tooltip.social',
name: t('nav_tooltip.social'),
icon: 'ri-group-line',
items: ['friend-log', 'friend-list', 'moderation']
},
{ type: 'item', key: 'notification' },
{ type: 'item', key: 'my-avatars' },
{
type: 'folder',
id: 'default-folder-charts',
nameKey: 'nav_tooltip.charts',
name: t('nav_tooltip.charts'),
icon: 'ri-pie-chart-line',
items: ['charts-instance', 'charts-mutual']
},
{ type: 'item', key: 'tools' },
{ type: 'item', key: 'direct-access' }
];
const menuItems = computed(() => {
const items = [];
navLayout.value.forEach((entry) => {
if (entry.type === 'item') {
const definition = navDefinitionMap.value.get(entry.key);
if (!definition) {
return;
}
items.push({
...definition,
index: definition.key,
title: definition.tooltip || definition.labelKey,
titleIsCustom: Boolean(definition.isDashboard)
});
return;
}
if (entry.type === 'folder') {
const folderDefinitions = (entry.items || [])
.map((key) => navDefinitionMap.value.get(key))
.filter(Boolean);
if (folderDefinitions.length === 0) {
return;
}
const folderEntries = folderDefinitions.map((definition) => ({
label: definition.labelKey,
routeName: definition.routeName,
routeParams: definition.routeParams,
index: definition.key,
icon: definition.icon,
action: definition.action,
titleIsCustom: Boolean(definition.isDashboard)
}));
items.push({
index: entry.id,
icon: entry.icon || DEFAULT_FOLDER_ICON,
title:
entry.name?.trim() ||
t('nav_menu.custom_nav.folder_name_placeholder'),
titleIsCustom: true,
children: folderEntries
});
}
});
return items;
});
const getFirstNavEntryLocal = (layout) => {
for (const entry of layout) {
if (entry.type === 'item') {
const definition = navDefinitionMap.value.get(entry.key);
if (
definition?.routeName ||
definition?.action ||
definition?.path
) {
return definition;
}
}
if (entry.type === 'folder' && entry.items?.length) {
const definition = entry.items
.map((key) => navDefinitionMap.value.get(key))
.find((def) => def?.routeName || def?.action || def?.path);
if (definition) {
return definition;
}
}
}
return null;
};
const getFirstNavKeyLocal = (layout) => {
const entry = getFirstNavEntryLocal(layout);
return entry?.key || null;
};
const activeMenuIndex = computed(() => {
const currentRoute = router.currentRoute.value;
if (currentRoute?.name === 'dashboard' && currentRoute.params?.id) {
return `${DASHBOARD_NAV_KEY_PREFIX}${currentRoute.params.id}`;
}
const currentRouteName = currentRoute?.name;
const navKey = currentRoute?.meta?.navKey || currentRouteName;
if (!navKey) {
return getFirstNavKeyLocal(navLayout.value) || 'feed';
}
for (const entry of navLayout.value) {
if (entry.type === 'item' && entry.key === navKey) {
return entry.key;
}
if (entry.type === 'folder' && entry.items?.includes(navKey)) {
return navKey;
}
}
return getFirstNavKeyLocal(navLayout.value) || 'feed';
});
const generateFolderId = () => {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `nav-folder-${crypto.randomUUID()}`;
}
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
};
const collectLayoutKeys = (layout) => {
const keys = new Set();
if (!Array.isArray(layout)) {
return keys;
}
layout.forEach((entry) => {
if (entry?.type === 'item' && entry.key) {
keys.add(entry.key);
return;
}
if (entry?.type === 'folder' && Array.isArray(entry.items)) {
entry.items.forEach((key) => {
if (key) {
keys.add(key);
}
});
}
});
return keys;
};
const getAppendDefinitions = (layout, hiddenKeys = []) => {
const keysInLayout = collectLayoutKeys(layout);
const hiddenSet = new Set(Array.isArray(hiddenKeys) ? hiddenKeys : []);
const dashboardDefinitions = dashboardStore
.getDashboardNavDefinitions()
.filter(
(definition) =>
keysInLayout.has(definition.key) ||
hiddenSet.has(definition.key)
);
return [...navDefinitions, ...dashboardDefinitions];
};
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
return sanitizeLayout(
layout,
hiddenKeys,
navDefinitionMap.value,
getAppendDefinitions(layout, hiddenKeys),
t,
generateFolderId
);
};
const defaultNavLayout = computed(() => {
const base = createDefaultNavLayout();
const dashboardEntries = dashboardStore
.getDashboardNavDefinitions()
.map((def) => ({ type: 'item', key: def.key }));
if (dashboardEntries.length) {
const directAccessIdx = base.findIndex(
(entry) =>
entry.type === 'item' && entry.key === 'direct-access'
);
if (directAccessIdx !== -1) {
base.splice(directAccessIdx, 0, ...dashboardEntries);
} else {
base.push(...dashboardEntries);
}
}
return sanitizeLayoutLocal(base, []);
});
const handleRouteChange = (routeName, routeParams = undefined) => {
if (!routeName) {
return;
}
if (routeParams) {
router.push({ name: routeName, params: routeParams });
return;
}
router.push({ name: routeName });
};
const triggerNavAction = (entry) => {
if (!entry) {
return;
}
if (entry.action === 'direct-access') {
directAccessPaste();
return;
}
if (entry.routeName) {
handleRouteChange(entry.routeName, entry.routeParams);
return;
}
if (entry.path) {
router.push(entry.path);
}
};
const saveNavLayout = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(
hiddenKeys,
navDefinitionMap.value
);
try {
await configRepository.setString(
'VRCX_customNavMenuLayoutList',
JSON.stringify({
layout,
hiddenKeys: normalizedHiddenKeys
})
);
} catch (error) {
console.error('Failed to save custom nav', error);
}
};
const applyCustomNavLayout = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(
hiddenKeys,
navDefinitionMap.value
);
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
navLayout.value = sanitized;
navHiddenKeys.value = normalizedHiddenKeys;
await saveNavLayout(sanitized, normalizedHiddenKeys);
};
let hasNavigatedToInitialRoute = false;
const navigateToFirstNavEntry = () => {
if (hasNavigatedToInitialRoute) {
return;
}
const firstEntry = getFirstNavEntryLocal(navLayout.value);
if (!firstEntry) {
return;
}
hasNavigatedToInitialRoute = true;
if (
router.currentRoute.value?.name !== firstEntry.routeName ||
(firstEntry.routeParams?.id &&
router.currentRoute.value?.params?.id !==
firstEntry.routeParams.id)
) {
triggerNavAction(firstEntry);
}
};
const loadNavMenuConfig = async () => {
let layoutData = null;
let hiddenKeysData = [];
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;
hiddenKeysData = Array.isArray(parsed.hiddenKeys)
? parsed.hiddenKeys
: [];
}
}
} catch (error) {
console.error('Failed to load custom nav', error);
} finally {
const normalizedHiddenKeys = normalizeHiddenKeys(
hiddenKeysData,
navDefinitionMap.value
);
const fallbackLayout = layoutData?.length
? layoutData
: createDefaultNavLayout();
const sanitized = sanitizeLayoutLocal(
fallbackLayout,
normalizedHiddenKeys
);
navLayout.value = sanitized;
navHiddenKeys.value = normalizedHiddenKeys;
if (
layoutData?.length &&
(JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout) ||
JSON.stringify(normalizedHiddenKeys) !==
JSON.stringify(hiddenKeysData))
) {
await saveNavLayout(sanitized, normalizedHiddenKeys);
}
navLayoutReady.value = true;
navigateToFirstNavEntry();
}
};
const cleanDashboardEntries = (layout, dashboardKeys) => {
const normalized = [];
layout.forEach((entry) => {
if (entry.type === 'item') {
if (
entry.key?.startsWith(DASHBOARD_NAV_KEY_PREFIX) &&
!dashboardKeys.has(entry.key)
) {
return;
}
normalized.push(entry);
return;
}
if (entry.type === 'folder') {
const nextItems = (entry.items || []).filter((key) => {
if (!key?.startsWith(DASHBOARD_NAV_KEY_PREFIX)) {
return true;
}
return dashboardKeys.has(key);
});
if (nextItems.length === 0) {
return;
}
normalized.push({ ...entry, items: nextItems });
}
});
return normalized;
};
watch(
() => locale.value,
() => {
if (!navLayoutReady.value) {
return;
}
navLayout.value = navLayout.value.map((entry) => {
if (entry.type === 'folder' && entry.nameKey) {
return {
...entry,
name: t(entry.nameKey)
};
}
return entry;
});
}
);
watch(
() => dashboards.value,
async () => {
if (!navLayoutReady.value) {
return;
}
const cleanedLayout = cleanDashboardEntries(
navLayout.value,
dashboardStore.dashboardNavKeys
);
const cleanedHidden = navHiddenKeys.value.filter(
(key) =>
!key?.startsWith(DASHBOARD_NAV_KEY_PREFIX) ||
dashboardStore.dashboardNavKeys.has(key)
);
if (
JSON.stringify(cleanedLayout) !==
JSON.stringify(navLayout.value) ||
JSON.stringify(cleanedHidden) !==
JSON.stringify(navHiddenKeys.value)
) {
await applyCustomNavLayout(cleanedLayout, cleanedHidden);
}
},
{ deep: true }
);
return {
navLayout,
navLayoutReady,
navHiddenKeys,
menuItems,
activeMenuIndex,
allNavDefinitions,
navDefinitionMap,
defaultNavLayout,
sanitizeLayoutLocal,
saveNavLayout,
applyCustomNavLayout,
loadNavMenuConfig,
triggerNavAction
};
}

View File

@@ -0,0 +1,70 @@
import { computed } from 'vue';
import { useThemeColor } from '@/shared/utils/base/ui';
import { THEME_CONFIG } from '../../../shared/constants';
export function useNavTheme({ t, appearanceSettingsStore }) {
const themes = computed(() => Object.keys(THEME_CONFIG));
const {
themeColors,
currentThemeColor,
isApplyingThemeColor,
applyThemeColor,
initThemeColor
} = useThemeColor();
const themeDisplayName = (themeKey) => {
const i18nKey = `view.settings.appearance.appearance.theme_mode_${themeKey}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return THEME_CONFIG[themeKey]?.name ?? themeKey;
};
const themeColorDisplayName = (theme) => {
if (!theme) {
return '';
}
const i18nKey = `view.settings.appearance.theme_color.${theme.key}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return theme.label || theme.key;
};
const handleThemeSelect = (theme) => {
appearanceSettingsStore.setThemeMode(theme);
};
const handleThemeToggle = () => {
appearanceSettingsStore.toggleThemeMode();
};
const handleTableDensitySelect = (density) => {
appearanceSettingsStore.setTableDensity(density);
};
const handleThemeColorSelect = async (theme) => {
if (!theme) {
return;
}
await applyThemeColor(theme.key);
};
return {
themes,
themeColors,
currentThemeColor,
isApplyingThemeColor,
initThemeColor,
themeDisplayName,
themeColorDisplayName,
handleThemeSelect,
handleThemeToggle,
handleTableDensitySelect,
handleThemeColorSelect
};
}

View File

@@ -127,6 +127,15 @@ export function sanitizeLayout(
appendChartsFolder();
}
// Ensure direct-access is always the last item
const directAccessIdx = normalized.findIndex(
(entry) => entry.type === 'item' && entry.key === 'direct-access'
);
if (directAccessIdx !== -1 && directAccessIdx !== normalized.length - 1) {
const [directAccessEntry] = normalized.splice(directAccessIdx, 1);
normalized.push(directAccessEntry);
}
return normalized;
}

View File

@@ -58,6 +58,8 @@
"update_available": "Update available",
"update": "Update",
"mark_all_read": "Mark All as Read",
"edit_dashboard": "Edit Dashboard",
"delete_dashboard": "Delete Dashboard",
"custom_nav": {
"header": "Customize Navigation",
"dialog_title": "Customize Navigation Menu",
@@ -66,6 +68,8 @@
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
"edit_folder": "Edit Folder",
"delete_folder": "Delete Folder",
"edit_dashboard": "Edit Dashboard",
"delete_dashboard": "Delete Dashboard",
"folder_empty": "This folder is empty",
"folder_drop_here": "Drag items here",
"hide": "Hide",
@@ -77,6 +81,39 @@
"restore_default_confirm": "Restore navigation to its default order?"
}
},
"dashboard": {
"default_name": "Dashboard",
"new_dashboard": "New Dashboard",
"empty": "This dashboard is empty",
"actions": {
"start_editing": "Start Editing",
"add_row": "Add Row",
"add_full_row": "Add Full Row",
"add_split_row": "Add Split Row",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
},
"toolbar": {
"editing": "Editing Dashboard",
"name_placeholder": "Dashboard Name",
"icon_placeholder": "Icon Class (Optional)"
},
"panel": {
"not_selected": "No Panel Selected",
"replace": "Replace Panel",
"select": "Select Panel",
"not_configured": "Panel Not Configured"
},
"selector": {
"title": "Select Panel Content",
"clear": "Clear Panel"
},
"confirmations": {
"delete_title": "Delete Dashboard",
"delete_description": "Are you sure you want to delete this dashboard? This action cannot be undone."
}
},
"side_panel": {
"search_placeholder": "Quick Search...",
"search_no_results": "No results found",

View File

@@ -9,6 +9,7 @@ import Feed from './../views/Feed/Feed.vue';
import FriendList from './../views/FriendList/FriendList.vue';
import FriendLog from './../views/FriendLog/FriendLog.vue';
import FriendsLocations from './../views/FriendsLocations/FriendsLocations.vue';
import Dashboard from './../views/Dashboard/Dashboard.vue';
import Gallery from './../views/Tools/Gallery.vue';
import GameLog from './../views/GameLog/GameLog.vue';
import Login from './../views/Login/Login.vue';
@@ -44,6 +45,13 @@ const routes = [
{ path: 'game-log', name: 'game-log', component: GameLog },
{ path: 'player-list', name: 'player-list', component: PlayerList },
{ path: 'search', name: 'search', component: Search },
{
path: 'dashboard/:id',
name: 'dashboard',
component: Dashboard,
props: true,
meta: { navKey: 'dashboard' }
},
{
path: 'favorites/friends',
name: 'favorite-friends',

View File

@@ -0,0 +1,3 @@
export const DASHBOARD_STORAGE_KEY = 'VRCX_dashboardConfigs';
export const DASHBOARD_NAV_KEY_PREFIX = 'dashboard-';
export const DEFAULT_DASHBOARD_ICON = 'ri-dashboard-line';

View File

@@ -13,3 +13,4 @@ export * from './link';
export * from './ui';
export * from './accessType';
export * from './tags';
export * from './dashboard';

209
src/stores/dashboard.js Normal file
View File

@@ -0,0 +1,209 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import configRepository from '../services/config';
import {
DASHBOARD_NAV_KEY_PREFIX,
DASHBOARD_STORAGE_KEY,
DEFAULT_DASHBOARD_ICON
} from '../shared/constants/dashboard';
function cloneRows(rows) {
if (!Array.isArray(rows)) {
return [];
}
return rows
.map((row) => {
const panels = Array.isArray(row?.panels)
? row.panels
.slice(0, 2)
.map((panel) =>
typeof panel === 'string' && panel ? panel : null
)
: [];
if (!panels.length) {
return null;
}
return { panels };
})
.filter(Boolean);
}
function sanitizeDashboard(dashboard) {
if (!dashboard || typeof dashboard !== 'object') {
return null;
}
const id =
typeof dashboard.id === 'string' && dashboard.id ? dashboard.id : null;
if (!id) {
return null;
}
const name =
typeof dashboard.name === 'string' && dashboard.name.trim()
? dashboard.name.trim()
: 'Dashboard';
const icon =
typeof dashboard.icon === 'string' && dashboard.icon.trim()
? dashboard.icon.trim()
: DEFAULT_DASHBOARD_ICON;
return {
id,
name,
icon,
rows: cloneRows(dashboard.rows)
};
}
export const useDashboardStore = defineStore('dashboard', () => {
const dashboards = ref([]);
const loaded = ref(false);
const editingDashboardId = ref(null);
const dashboardNavKeys = computed(
() =>
new Set(
dashboards.value.map(
(dashboard) => `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}`
)
)
);
async function loadDashboards() {
try {
const stored = await configRepository.getString(
DASHBOARD_STORAGE_KEY,
null
);
if (!stored) {
dashboards.value = [];
loaded.value = true;
return;
}
const parsed = JSON.parse(stored);
const source = Array.isArray(parsed?.dashboards)
? parsed.dashboards
: [];
dashboards.value = source.map(sanitizeDashboard).filter(Boolean);
} catch {
dashboards.value = [];
} finally {
loaded.value = true;
}
}
async function saveDashboards() {
await configRepository.setString(
DASHBOARD_STORAGE_KEY,
JSON.stringify({ dashboards: dashboards.value })
);
}
function ensureLoaded() {
if (!loaded.value) {
loadDashboards();
}
}
function getDashboard(id) {
return (
dashboards.value.find((dashboard) => dashboard.id === id) || null
);
}
function generateDashboardId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `dashboard-${Date.now()}-${Math.random().toString().slice(2, 8)}`;
}
async function createDashboard(name = 'Dashboard') {
const id = generateDashboardId();
const dashboard = {
id,
name,
icon: DEFAULT_DASHBOARD_ICON,
rows: []
};
dashboards.value.push(dashboard);
await saveDashboards();
return dashboard;
}
async function updateDashboard(id, updates) {
const index = dashboards.value.findIndex(
(dashboard) => dashboard.id === id
);
if (index < 0) {
return;
}
const next = sanitizeDashboard({
...dashboards.value[index],
...updates,
id
});
if (!next) {
return;
}
dashboards.value[index] = next;
await saveDashboards();
}
async function deleteDashboard(id) {
dashboards.value = dashboards.value.filter(
(dashboard) => dashboard.id !== id
);
if (editingDashboardId.value === id) {
editingDashboardId.value = null;
}
await saveDashboards();
}
function setEditingDashboardId(id) {
editingDashboardId.value = id || null;
}
function clearEditingDashboardId() {
editingDashboardId.value = null;
}
function getDashboardNavDefinitions() {
return dashboards.value.map((dashboard) => ({
key: `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}`,
icon: dashboard.icon || DEFAULT_DASHBOARD_ICON,
tooltip: dashboard.name,
labelKey: dashboard.name,
routeName: 'dashboard',
routeParams: { id: dashboard.id },
isDashboard: true
}));
}
return {
dashboards,
loaded,
editingDashboardId,
dashboardNavKeys,
ensureLoaded,
loadDashboards,
saveDashboards,
createDashboard,
getDashboard,
updateDashboard,
deleteDashboard,
getDashboardNavDefinitions,
setEditingDashboardId,
clearEditingDashboardId
};
});

View File

@@ -8,6 +8,7 @@ import { useAuthStore } from './auth';
import { useAvatarProviderStore } from './avatarProvider';
import { useAvatarStore } from './avatar';
import { useChartsStore } from './charts';
import { useDashboardStore } from './dashboard';
import { useDiscordPresenceSettingsStore } from './settings/discordPresence';
import { useFavoriteStore } from './favorite';
import { useFeedStore } from './feed';
@@ -164,6 +165,7 @@ export function createGlobalStores() {
auth: useAuthStore(),
vrcStatus: useVrcStatusStore(),
charts: useChartsStore(),
dashboard: useDashboardStore(),
modal: useModalStore(),
globalSearch: useGlobalSearchStore()
};
@@ -189,6 +191,7 @@ export {
usePhotonStore,
useSearchStore,
useChartsStore,
useDashboardStore,
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useDiscordPresenceSettingsStore,

View File

@@ -0,0 +1,186 @@
<template>
<div class="x-container flex h-full min-h-0 flex-col gap-3 py-3">
<DashboardEditToolbar
v-if="isEditing"
v-model:name="editName"
@save="handleSave"
@cancel="handleCancelEdit"
@delete="handleDelete" />
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<template v-if="displayRows.length && !isEditing">
<ResizablePanelGroup direction="vertical" :auto-save-id="`dashboard-${id}`" class="flex-1 min-h-0">
<template v-for="(row, rowIndex) in displayRows" :key="rowIndex">
<ResizablePanel :default-size="100 / displayRows.length" :min-size="10">
<DashboardRow :row="row" :row-index="rowIndex" :dashboard-id="id" />
</ResizablePanel>
<ResizableHandle v-if="rowIndex < displayRows.length - 1" />
</template>
</ResizablePanelGroup>
</template>
<template v-else-if="isEditing">
<DashboardRow
v-for="(row, rowIndex) in displayRows"
:key="rowIndex"
:row="row"
:row-index="rowIndex"
:dashboard-id="id"
:is-editing="true"
@update-panel="handleUpdatePanel"
@remove-row="handleRemoveRow" />
<div
class="mt-auto flex min-h-[80px] flex-1 items-center justify-center rounded-md border-2 border-dashed border-muted-foreground/20 text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
:class="showAddRowOptions ? 'items-start p-4' : 'cursor-pointer'"
@click="handleAddRowAreaClick">
<div v-if="showAddRowOptions" class="flex flex-wrap items-center gap-3">
<span class="text-xs text-muted-foreground">{{ t('dashboard.actions.add_row') }}:</span>
<button
type="button"
class="flex h-10 w-16 items-center justify-center rounded-md border-2 border-dashed border-muted-foreground/30 transition-colors hover:border-primary/50 hover:bg-primary/5"
@click.stop="handleAddRow(1)">
<div class="h-6 w-12 rounded bg-muted-foreground/20" />
</button>
<button
type="button"
class="flex h-10 w-16 items-center justify-center gap-1 rounded-md border-2 border-dashed border-muted-foreground/30 transition-colors hover:border-primary/50 hover:bg-primary/5"
@click.stop="handleAddRow(2)">
<div class="h-6 w-5 rounded bg-muted-foreground/20" />
<div class="h-6 w-5 rounded bg-muted-foreground/20" />
</button>
</div>
<Plus v-else class="size-6 opacity-50" />
</div>
</template>
<div
v-else
class="flex flex-1 items-center justify-center rounded-md border border-dashed text-muted-foreground">
<div class="flex flex-col items-center gap-3">
<p>{{ t('dashboard.empty') }}</p>
<Button @click="isEditing = true">{{ t('dashboard.actions.start_editing') }}</Button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Plus } from 'lucide-vue-next';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { Button } from '@/components/ui/button';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { useDashboardStore, useModalStore } from '@/stores';
import DashboardEditToolbar from './components/DashboardEditToolbar.vue';
import DashboardRow from './components/DashboardRow.vue';
const props = defineProps({
id: {
type: String,
required: true
}
});
const router = useRouter();
const { t } = useI18n();
const dashboardStore = useDashboardStore();
const modalStore = useModalStore();
const isEditing = ref(false);
const showAddRowOptions = ref(false);
const editRows = ref([]);
const editName = ref('');
const dashboard = computed(() => dashboardStore.getDashboard(props.id));
const displayRows = computed(() => (isEditing.value ? editRows.value : dashboard.value?.rows || []));
const cloneRows = (rows) => JSON.parse(JSON.stringify(Array.isArray(rows) ? rows : []));
watch(
() => props.id,
() => {
isEditing.value = false;
showAddRowOptions.value = false;
}
);
watch(
dashboard,
(value) => {
if (!value) {
router.replace({ name: 'feed' });
}
},
{ immediate: true }
);
watch(isEditing, (editing) => {
if (!editing || !dashboard.value) {
showAddRowOptions.value = false;
return;
}
editRows.value = cloneRows(dashboard.value.rows);
editName.value = dashboard.value.name || '';
});
watch(
() => dashboardStore.editingDashboardId,
(editingId) => {
if (editingId === props.id) {
isEditing.value = true;
dashboardStore.clearEditingDashboardId();
}
},
{ immediate: true }
);
const handleAddRowAreaClick = () => {
showAddRowOptions.value = !showAddRowOptions.value;
};
const handleAddRow = (panelCount) => {
const panels = Array(panelCount).fill(null);
editRows.value.push({ panels });
showAddRowOptions.value = false;
};
const handleRemoveRow = (rowIndex) => {
editRows.value.splice(rowIndex, 1);
};
const handleUpdatePanel = (rowIndex, panelIndex, panelKey) => {
if (!editRows.value[rowIndex]?.panels) {
return;
}
editRows.value[rowIndex].panels[panelIndex] = panelKey;
};
const handleSave = async () => {
await dashboardStore.updateDashboard(props.id, {
name: editName.value.trim() || dashboard.value?.name || 'Dashboard',
rows: editRows.value
});
isEditing.value = false;
};
const handleCancelEdit = () => {
isEditing.value = false;
};
const handleDelete = async () => {
const { ok } = await modalStore.confirm({
title: t('dashboard.confirmations.delete_title'),
description: t('dashboard.confirmations.delete_description')
});
if (!ok) {
return;
}
await dashboardStore.deleteDashboard(props.id);
router.replace({ name: 'feed' });
};
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex items-center gap-2 rounded-md border bg-card px-3 py-2">
<span class="text-sm font-medium text-muted-foreground">{{ t('dashboard.toolbar.editing') }}</span>
<Input
:model-value="name"
:placeholder="t('dashboard.name_placeholder')"
class="mx-2 h-7 max-w-[200px] text-sm"
@update:model-value="emit('update:name', $event)" />
<div class="flex gap-2">
<Button variant="secondary" size="sm" @click="emit('cancel')">{{ t('dashboard.actions.cancel') }}</Button>
<Button variant="destructive" size="sm" @click="emit('delete')">{{ t('dashboard.actions.delete') }}</Button>
</div>
<Button class="ml-auto" size="sm" @click="emit('save')">{{ t('dashboard.actions.save') }}</Button>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
defineProps({
name: {
type: String,
default: ''
}
});
const emit = defineEmits(['save', 'cancel', 'delete', 'update:name']);
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div class="relative flex min-h-0 flex-1 overflow-hidden rounded-md border bg-card">
<template v-if="isEditing">
<div class="flex w-full min-h-0 flex-col gap-2 p-3">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
<span>{{ panelLabel || t('dashboard.panel.not_selected') }}</span>
</div>
<Button variant="outline" class="w-full" @click="openSelector">
{{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }}
</Button>
</div>
</template>
<template v-else-if="panelKey && panelComponent">
<div class="h-full w-full overflow-y-auto">
<component :is="panelComponent" />
</div>
</template>
<div v-else class="flex w-full items-center justify-center text-sm text-muted-foreground">
{{ t('dashboard.panel.not_configured') }}
</div>
<PanelSelector
:open="selectorOpen"
:current-key="panelKey"
@select="handleSelect"
@close="selectorOpen = false" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { navDefinitions } from '@/shared/constants/ui';
import PanelSelector from './PanelSelector.vue';
import { panelComponentMap } from './panelRegistry';
const props = defineProps({
panelKey: {
type: String,
default: null
},
isEditing: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const selectorOpen = ref(false);
const panelComponent = computed(() => {
if (!props.panelKey) {
return null;
}
return panelComponentMap[props.panelKey] || null;
});
const panelOption = computed(() => {
if (!props.panelKey) {
return null;
}
return navDefinitions.find((def) => def.key === props.panelKey) || null;
});
const panelLabel = computed(() => {
if (!panelOption.value?.labelKey) {
return props.panelKey || '';
}
return t(panelOption.value.labelKey);
});
const panelIcon = computed(() => panelOption.value?.icon || '');
const openSelector = () => {
selectorOpen.value = true;
};
const handleSelect = (value) => {
emit('select', value);
selectorOpen.value = false;
};
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="relative h-full min-h-[180px]">
<div v-if="isEditing" class="flex h-full gap-2">
<DashboardPanel
v-for="(panelKey, panelIndex) in row.panels"
:key="panelIndex"
:panel-key="panelKey"
:is-editing="true"
:class="row.panels.length === 1 ? 'w-full' : 'w-1/2'"
@select="(key) => emit('update-panel', rowIndex, panelIndex, key)" />
<Button
variant="ghost"
size="icon-sm"
class="absolute -right-1 top-2 z-20 bg-background/80"
@click="emit('remove-row', rowIndex)">
<X class="size-4" />
</Button>
</div>
<ResizablePanelGroup
v-else-if="row.panels.length === 2"
direction="horizontal"
:auto-save-id="`dashboard-${dashboardId}-row-${rowIndex}`"
class="h-full min-h-[180px]">
<ResizablePanel :default-size="50" :min-size="20">
<DashboardPanel :panel-key="row.panels[0]" class="h-full" />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel :default-size="50" :min-size="20">
<DashboardPanel :panel-key="row.panels[1]" class="h-full" />
</ResizablePanel>
</ResizablePanelGroup>
<div v-else class="h-full">
<DashboardPanel :panel-key="row.panels[0]" class="h-full" />
</div>
</div>
</template>
<script setup>
import { X } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import DashboardPanel from './DashboardPanel.vue';
defineProps({
row: {
type: Object,
required: true
},
rowIndex: {
type: Number,
required: true
},
dashboardId: {
type: String,
required: true
},
isEditing: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update-panel', 'remove-row']);
</script>

View File

@@ -0,0 +1,52 @@
<template>
<Dialog :open="open" @update:open="(value) => !value && emit('close')">
<DialogContent class="sm:max-w-110">
<DialogHeader>
<DialogTitle>{{ t('dashboard.selector.title') }}</DialogTitle>
</DialogHeader>
<div class="grid grid-cols-2 gap-2 max-h-[50vh] overflow-y-auto">
<button
v-for="option in panelOptions"
:key="option.key"
type="button"
class="flex items-center gap-2 rounded-md border p-2 text-left text-sm hover:bg-accent"
:class="option.key === currentKey ? 'border-primary bg-primary/5 ring-1 ring-primary/40' : ''"
@click="emit('select', option.key)">
<i :class="option.icon" class="text-base" />
<span>{{ t(option.labelKey) }}</span>
</button>
</div>
<DialogFooter>
<Button variant="ghost" @click="emit('select', null)">{{ t('dashboard.selector.clear') }}</Button>
<Button variant="secondary" @click="emit('close')">{{ t('dashboard.actions.cancel') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { navDefinitions } from '@/shared/constants/ui';
defineProps({
open: {
type: Boolean,
default: false
},
currentKey: {
type: String,
default: null
}
});
const emit = defineEmits(['select', 'close']);
const { t } = useI18n();
const panelOptions = computed(() => navDefinitions.filter((def) => def.routeName));
</script>

View File

@@ -0,0 +1,33 @@
import Feed from '../../Feed/Feed.vue';
import FavoritesAvatar from '../../Favorites/FavoritesAvatar.vue';
import FavoritesFriend from '../../Favorites/FavoritesFriend.vue';
import FavoritesWorld from '../../Favorites/FavoritesWorld.vue';
import FriendList from '../../FriendList/FriendList.vue';
import FriendLog from '../../FriendLog/FriendLog.vue';
import FriendsLocations from '../../FriendsLocations/FriendsLocations.vue';
import GameLog from '../../GameLog/GameLog.vue';
import Moderation from '../../Moderation/Moderation.vue';
import MyAvatars from '../../MyAvatars/MyAvatars.vue';
import Notification from '../../Notifications/Notification.vue';
import PlayerList from '../../PlayerList/PlayerList.vue';
import Search from '../../Search/Search.vue';
import Tools from '../../Tools/Tools.vue';
export const panelComponentMap = {
feed: Feed,
'friends-locations': FriendsLocations,
'game-log': GameLog,
'player-list': PlayerList,
search: Search,
'favorite-friends': FavoritesFriend,
'favorite-worlds': FavoritesWorld,
'favorite-avatars': FavoritesAvatar,
'friend-log': FriendLog,
'friend-list': FriendList,
moderation: Moderation,
notification: Notification,
'my-avatars': MyAvatars,
'charts-instance': () => import('../../Charts/components/InstanceActivity.vue'),
'charts-mutual': () => import('../../Charts/components/MutualFriends.vue'),
tools: Tools
};

View File

@@ -108,7 +108,7 @@
import LaunchDialog from '../../components/dialogs/LaunchDialog.vue';
import LaunchOptionsDialog from '../Settings/dialogs/LaunchOptionsDialog.vue';
import MainDialogContainer from '../../components/dialogs/MainDialogContainer.vue';
import NavMenu from '../../components/NavMenu.vue';
import NavMenu from '../../components/nav-menu/NavMenu.vue';
import PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue';
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
import Sidebar from '../Sidebar/Sidebar.vue';

View File

@@ -11,7 +11,7 @@ vi.mock('../../../stores', () => ({ useAppearanceSettingsStore: () => ({ navWidt
vi.mock('../../../composables/useMainLayoutResizable', () => ({ useMainLayoutResizable: () => ({ asideDefaultSize: 30, asideMinSize: 0, asideMaxPx: 480, mainDefaultSize: 70, handleLayout: vi.fn(), isAsideCollapsed: () => false, isAsideCollapsedStatic: false, isSideBarTabShow: ref(true) }) }));
vi.mock('../../../components/ui/resizable', () => ({ ResizablePanelGroup: { template: '<div><slot :layout="[]" /></div>' }, ResizablePanel: { template: '<div><slot /></div>' }, ResizableHandle: { template: '<div />' } }));
vi.mock('../../../components/ui/sidebar', () => ({ SidebarProvider: { template: '<div><slot /></div>' }, SidebarInset: { template: '<div><slot /></div>' } }));
vi.mock('../../../components/NavMenu.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../../../components/nav-menu/NavMenu.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../../Sidebar/Sidebar.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../../../components/StatusBar.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../../../components/dialogs/MainDialogContainer.vue', () => ({ default: { template: '<div />' } }));