mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
feat: add dashboard
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ bun.lock
|
||||
AGENTS.md
|
||||
AI_GUIDE.md
|
||||
CLAUDE.md
|
||||
.coverage/
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>' },
|
||||
|
||||
443
src/components/nav-menu/NavMenu.vue
Normal file
443
src/components/nav-menu/NavMenu.vue
Normal 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>
|
||||
228
src/components/nav-menu/NavMenuFolderItem.vue
Normal file
228
src/components/nav-menu/NavMenuFolderItem.vue
Normal 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>
|
||||
270
src/components/nav-menu/NavMenuFooter.vue
Normal file
270
src/components/nav-menu/NavMenuFooter.vue
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
97
src/components/nav-menu/__tests__/NavMenuFolderItem.test.js
Normal file
97
src/components/nav-menu/__tests__/NavMenuFolderItem.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
77
src/components/nav-menu/__tests__/NavMenuFooter.test.js
Normal file
77
src/components/nav-menu/__tests__/NavMenuFooter.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
466
src/components/nav-menu/composables/useNavLayout.js
Normal file
466
src/components/nav-menu/composables/useNavLayout.js
Normal 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
|
||||
};
|
||||
}
|
||||
70
src/components/nav-menu/composables/useNavTheme.js
Normal file
70
src/components/nav-menu/composables/useNavTheme.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
3
src/shared/constants/dashboard.js
Normal file
3
src/shared/constants/dashboard.js
Normal 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';
|
||||
@@ -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
209
src/stores/dashboard.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
186
src/views/Dashboard/Dashboard.vue
Normal file
186
src/views/Dashboard/Dashboard.vue
Normal 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>
|
||||
31
src/views/Dashboard/components/DashboardEditToolbar.vue
Normal file
31
src/views/Dashboard/components/DashboardEditToolbar.vue
Normal 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>
|
||||
89
src/views/Dashboard/components/DashboardPanel.vue
Normal file
89
src/views/Dashboard/components/DashboardPanel.vue
Normal 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>
|
||||
69
src/views/Dashboard/components/DashboardRow.vue
Normal file
69
src/views/Dashboard/components/DashboardRow.vue
Normal 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>
|
||||
52
src/views/Dashboard/components/PanelSelector.vue
Normal file
52
src/views/Dashboard/components/PanelSelector.vue
Normal 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>
|
||||
33
src/views/Dashboard/components/panelRegistry.js
Normal file
33
src/views/Dashboard/components/panelRegistry.js
Normal 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
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />' } }));
|
||||
|
||||
Reference in New Issue
Block a user