mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 14:53:50 +02:00
feat: add dashboard
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ bun.lock
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
AI_GUIDE.md
|
AI_GUIDE.md
|
||||||
CLAUDE.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"
|
:drag-state="dragState"
|
||||||
@edit-folder="openFolderEditor"
|
@edit-folder="openFolderEditor"
|
||||||
@delete-folder="handleDeleteFolder"
|
@delete-folder="handleDeleteFolder"
|
||||||
|
@edit-dashboard="openDashboardEditor"
|
||||||
|
@delete-dashboard="handleDeleteDashboard"
|
||||||
@hide="handleHideItem"
|
@hide="handleHideItem"
|
||||||
@toggle="handleTreeToggle(item)" />
|
@toggle="handleTreeToggle(item)" />
|
||||||
</template>
|
</template>
|
||||||
@@ -73,6 +75,9 @@
|
|||||||
<Button variant="outline" @click="handleAddFolder">
|
<Button variant="outline" @click="handleAddFolder">
|
||||||
{{ t('nav_menu.custom_nav.new_folder') }}
|
{{ t('nav_menu.custom_nav.new_folder') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" @click="handleAddDashboard">
|
||||||
|
{{ t('dashboard.new_dashboard') }}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" class="text-destructive" @click="handleReset">
|
<Button variant="ghost" class="text-destructive" @click="handleReset">
|
||||||
{{ t('nav_menu.custom_nav.restore_default') }}
|
{{ t('nav_menu.custom_nav.restore_default') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -93,7 +98,13 @@
|
|||||||
<Dialog v-model:open="folderEditor.visible">
|
<Dialog v-model:open="folderEditor.visible">
|
||||||
<DialogContent class="sm:max-w-100">
|
<DialogContent class="sm:max-w-100">
|
||||||
<DialogHeader>
|
<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>
|
</DialogHeader>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<InputGroupField
|
<InputGroupField
|
||||||
@@ -159,6 +170,8 @@
|
|||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import { Tree } from '../ui/tree';
|
import { Tree } from '../ui/tree';
|
||||||
import { navDefinitions } from '../../shared/constants/ui.js';
|
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';
|
import SortableTreeNode from './SortableTreeNode.vue';
|
||||||
|
|
||||||
@@ -178,11 +191,17 @@
|
|||||||
defaultLayout: {
|
defaultLayout: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
definitions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:visible', 'save']);
|
const emit = defineEmits(['update:visible', 'save', 'dashboard-created']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const dashboardStore = useDashboardStore();
|
||||||
|
const modalStore = useModalStore();
|
||||||
|
|
||||||
const cloneLayout = (source) => {
|
const cloneLayout = (source) => {
|
||||||
if (!Array.isArray(source)) return [];
|
if (!Array.isArray(source)) return [];
|
||||||
@@ -210,6 +229,7 @@
|
|||||||
visible: false,
|
visible: false,
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
editingId: null,
|
editingId: null,
|
||||||
|
editorType: 'folder',
|
||||||
data: { id: '', name: '', icon: '' }
|
data: { id: '', name: '', icon: '' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,49 +263,53 @@
|
|||||||
|
|
||||||
const definitionsMap = computed(() => {
|
const definitionsMap = computed(() => {
|
||||||
const map = new Map();
|
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);
|
if (def?.key) map.set(def.key, def);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const treeItems = computed(() => {
|
const treeItems = computed(() => {
|
||||||
return localLayout.value.map((entry) => {
|
return localLayout.value
|
||||||
if (entry.type === 'folder') {
|
.map((entry) => {
|
||||||
const children = (entry.items || [])
|
if (entry.type === 'folder') {
|
||||||
.map((key) => {
|
const children = (entry.items || [])
|
||||||
const def = definitionsMap.value.get(key);
|
.map((key) => {
|
||||||
if (!def) return null;
|
const def = definitionsMap.value.get(key);
|
||||||
return { id: key, type: 'item', key, level: 1, parentId: entry.id };
|
if (!def) return null;
|
||||||
})
|
return { id: key, type: 'item', key, level: 1, parentId: entry.id };
|
||||||
.filter(Boolean);
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const folderChildren = children.length
|
const folderChildren = children.length
|
||||||
? children
|
? children
|
||||||
: [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }];
|
: [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
level: 0,
|
level: 0,
|
||||||
children: folderChildren
|
children: folderChildren
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { id: entry.key, type: 'item', key: entry.key, level: 0 };
|
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 expandedKeys = ref([]);
|
||||||
|
|
||||||
const hiddenItems = computed(() =>
|
const hiddenItems = computed(() =>
|
||||||
navDefinitions
|
(props.definitions?.length ? props.definitions : navDefinitions)
|
||||||
.filter((def) => hiddenKeySet.value.has(def.key))
|
.filter((def) => hiddenKeySet.value.has(def.key))
|
||||||
.map((def) => ({
|
.map((def) => ({
|
||||||
key: def.key,
|
key: def.key,
|
||||||
icon: def.icon,
|
icon: def.icon,
|
||||||
label: t(def.labelKey)
|
label: def.isDashboard ? def.labelKey : t(def.labelKey)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -646,13 +670,31 @@
|
|||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
folderEditor.isEditing = true;
|
folderEditor.isEditing = true;
|
||||||
folderEditor.editingId = folderId;
|
folderEditor.editingId = folderId;
|
||||||
|
folderEditor.editorType = 'folder';
|
||||||
folderEditor.data = { id: entry.id, name: entry.name, icon: entry.icon };
|
folderEditor.data = { id: entry.id, name: entry.name, icon: entry.icon };
|
||||||
folderEditor.visible = true;
|
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 = () => {
|
const handleAddFolder = () => {
|
||||||
folderEditor.isEditing = false;
|
folderEditor.isEditing = false;
|
||||||
folderEditor.editingId = null;
|
folderEditor.editingId = null;
|
||||||
|
folderEditor.editorType = 'folder';
|
||||||
folderEditor.data = {
|
folderEditor.data = {
|
||||||
id: createFolderId(),
|
id: createFolderId(),
|
||||||
name: '',
|
name: '',
|
||||||
@@ -661,10 +703,27 @@
|
|||||||
folderEditor.visible = true;
|
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.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);
|
const entry = localLayout.value.find((e) => e.type === 'folder' && e.id === folderEditor.editingId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.name = folderEditor.data.name.trim();
|
entry.name = folderEditor.data.name.trim();
|
||||||
@@ -689,6 +748,43 @@
|
|||||||
folderEditor.visible = false;
|
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 handleSave = () => {
|
||||||
const cleanedLayout = localLayout.value.filter(
|
const cleanedLayout = localLayout.value.filter(
|
||||||
(entry) => !(entry.type === 'folder' && (!entry.items || entry.items.length === 0))
|
(entry) => !(entry.type === 'folder' && (!entry.items || entry.items.length === 0))
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
dragState: { type: Object, default: () => ({}) }
|
dragState: { type: Object, default: () => ({}) }
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['editFolder', 'deleteFolder', 'hide', 'toggle']);
|
const emit = defineEmits(['editFolder', 'deleteFolder', 'editDashboard', 'deleteDashboard', 'hide', 'toggle']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
|
|
||||||
const nodeValue = computed(() => props.item.value);
|
const nodeValue = computed(() => props.item.value);
|
||||||
const isFolder = computed(() => nodeValue.value?.type === 'folder');
|
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 hasChildren = computed(() => props.item.hasChildren);
|
||||||
const level = computed(() => nodeValue.value?.level ?? 0);
|
const level = computed(() => nodeValue.value?.level ?? 0);
|
||||||
const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key));
|
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');
|
return nodeValue.value.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder');
|
||||||
}
|
}
|
||||||
const def = props.definitionsMap.get(nodeValue.value?.key);
|
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(() => {
|
const displayIcon = computed(() => {
|
||||||
@@ -111,6 +117,14 @@
|
|||||||
{{ t('nav_menu.custom_nav.delete_folder') }}
|
{{ t('nav_menu.custom_nav.delete_folder') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</template>
|
</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>
|
<template v-else>
|
||||||
<DropdownMenuItem @click="emit('hide', nodeValue.key)">
|
<DropdownMenuItem @click="emit('hide', nodeValue.key)">
|
||||||
{{ t('nav_menu.custom_nav.hide') }}
|
{{ 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('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
|
||||||
vi.mock('@/shared/utils/common', () => ({ openExternalLink: vi.fn() }));
|
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', () => ({
|
vi.mock('@/components/ui/dialog', () => ({
|
||||||
Dialog: { template: '<div><slot /></div>' },
|
Dialog: { template: '<div><slot /></div>' },
|
||||||
DialogContent: { 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' },
|
tableDensity: { value: 'standard' },
|
||||||
isDarkMode: { value: false },
|
isDarkMode: { value: false },
|
||||||
isNavCollapsed: { 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) => {
|
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 />' }
|
default: { template: '<div />' }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../views/Feed/columns.jsx', () => ({
|
vi.mock('../../../views/Feed/columns.jsx', () => ({
|
||||||
columns: []
|
columns: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../plugins/router', () => ({
|
vi.mock('../../../plugins/router', () => ({
|
||||||
router: {
|
router: {
|
||||||
beforeEach: vi.fn(),
|
beforeEach: vi.fn(),
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
@@ -60,11 +66,11 @@ vi.mock('../../plugins/router', () => ({
|
|||||||
initRouter: vi.fn()
|
initRouter: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../plugins/interopApi', () => ({
|
vi.mock('../../../plugins/interopApi', () => ({
|
||||||
initInteropApi: vi.fn()
|
initInteropApi: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/database', () => ({
|
vi.mock('../../../services/database', () => ({
|
||||||
database: new Proxy(
|
database: new Proxy(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -76,11 +82,11 @@ vi.mock('../../services/database', () => ({
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/jsonStorage', () => ({
|
vi.mock('../../../services/jsonStorage', () => ({
|
||||||
default: vi.fn()
|
default: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/watchState', () => ({
|
vi.mock('../../../services/watchState', () => ({
|
||||||
watchState: { isLoggedIn: false }
|
watchState: { isLoggedIn: false }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ vi.mock('vue-router', () => ({
|
|||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../stores', () => ({
|
vi.mock('../../../stores', () => ({
|
||||||
useVRCXUpdaterStore: () => ({
|
useVRCXUpdaterStore: () => ({
|
||||||
pendingVRCXUpdate: mocks.pendingVRCXUpdate,
|
pendingVRCXUpdate: mocks.pendingVRCXUpdate,
|
||||||
pendingVRCXInstall: mocks.pendingVRCXInstall,
|
pendingVRCXInstall: mocks.pendingVRCXInstall,
|
||||||
@@ -118,17 +124,30 @@ vi.mock('../../stores', () => ({
|
|||||||
toggleThemeMode: (...args) => mocks.toggleThemeMode(...args),
|
toggleThemeMode: (...args) => mocks.toggleThemeMode(...args),
|
||||||
setTableDensity: vi.fn(),
|
setTableDensity: vi.fn(),
|
||||||
toggleNavCollapsed: (...args) => mocks.toggleNavCollapsed(...args)
|
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: {
|
default: {
|
||||||
getString: (...args) => mocks.getString(...args),
|
getString: (...args) => mocks.getString(...args),
|
||||||
setString: (...args) => mocks.setString(...args)
|
setString: (...args) => mocks.setString(...args)
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../shared/constants', () => ({
|
vi.mock('../../../shared/constants', () => ({
|
||||||
|
DASHBOARD_NAV_KEY_PREFIX: 'dashboard-',
|
||||||
THEME_CONFIG: {
|
THEME_CONFIG: {
|
||||||
system: { name: 'System' },
|
system: { name: 'System' },
|
||||||
light: { name: 'Light' },
|
light: { name: 'Light' },
|
||||||
@@ -155,20 +174,22 @@ vi.mock('../../shared/constants', () => ({
|
|||||||
]
|
]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./navMenuUtils', () => ({
|
vi.mock('../navMenuUtils', () => ({
|
||||||
getFirstNavRoute: () => 'feed',
|
getFirstNavRoute: () => 'feed',
|
||||||
isEntryNotified: () => false,
|
isEntryNotified: () => false,
|
||||||
normalizeHiddenKeys: (keys) => keys || [],
|
normalizeHiddenKeys: (keys) => keys || [],
|
||||||
sanitizeLayout: (layout) => layout
|
sanitizeLayout: (layout) => layout
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../shared/utils', () => ({
|
vi.mock('../../../shared/utils', () => ({
|
||||||
openExternalLink: (...args) => mocks.openExternalLink(...args)
|
openExternalLink: (...args) => mocks.openExternalLink(...args)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/shared/utils/base/ui', () => ({
|
vi.mock('@/shared/utils/base/ui', () => ({
|
||||||
useThemeColor: () => ({
|
useThemeColor: () => ({
|
||||||
themeColors: { value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }] },
|
themeColors: {
|
||||||
|
value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }]
|
||||||
|
},
|
||||||
currentThemeColor: { value: 'blue' },
|
currentThemeColor: { value: 'blue' },
|
||||||
isApplyingThemeColor: { value: false },
|
isApplyingThemeColor: { value: false },
|
||||||
applyThemeColor: (...args) => mocks.applyThemeColor(...args),
|
applyThemeColor: (...args) => mocks.applyThemeColor(...args),
|
||||||
@@ -178,6 +199,7 @@ vi.mock('@/shared/utils/base/ui', () => ({
|
|||||||
|
|
||||||
vi.mock('@/components/ui/sidebar', () => ({
|
vi.mock('@/components/ui/sidebar', () => ({
|
||||||
Sidebar: { template: '<div><slot /></div>' },
|
Sidebar: { template: '<div><slot /></div>' },
|
||||||
|
SidebarHeader: { template: '<div><slot /></div>' },
|
||||||
SidebarContent: { template: '<div><slot /></div>' },
|
SidebarContent: { template: '<div><slot /></div>' },
|
||||||
SidebarFooter: { template: '<div><slot /></div>' },
|
SidebarFooter: { template: '<div><slot /></div>' },
|
||||||
SidebarGroup: { template: '<div><slot /></div>' },
|
SidebarGroup: { template: '<div><slot /></div>' },
|
||||||
@@ -188,11 +210,13 @@ vi.mock('@/components/ui/sidebar', () => ({
|
|||||||
SidebarMenuSubItem: { template: '<div><slot /></div>' },
|
SidebarMenuSubItem: { template: '<div><slot /></div>' },
|
||||||
SidebarMenuButton: {
|
SidebarMenuButton: {
|
||||||
emits: ['click'],
|
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: {
|
SidebarMenuSubButton: {
|
||||||
emits: ['click'],
|
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>' },
|
DropdownMenu: { template: '<div><slot /></div>' },
|
||||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||||
DropdownMenuContent: { 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 />' },
|
DropdownMenuSeparator: { template: '<hr />' },
|
||||||
DropdownMenuLabel: { template: '<div><slot /></div>' },
|
DropdownMenuLabel: { template: '<div><slot /></div>' },
|
||||||
DropdownMenuSub: { template: '<div><slot /></div>' },
|
DropdownMenuSub: { template: '<div><slot /></div>' },
|
||||||
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
|
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
|
||||||
DropdownMenuSubContent: { 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', () => ({
|
vi.mock('@/components/ui/context-menu', () => ({
|
||||||
ContextMenu: { template: '<div><slot /></div>' },
|
ContextMenu: { template: '<div><slot /></div>' },
|
||||||
ContextMenuTrigger: { template: '<div><slot /></div>' },
|
ContextMenuTrigger: { template: '<div><slot /></div>' },
|
||||||
ContextMenuContent: { 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 />' }
|
ContextMenuSeparator: { template: '<hr />' }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -233,7 +269,8 @@ vi.mock('@/components/ui/tooltip', () => ({
|
|||||||
|
|
||||||
vi.mock('lucide-vue-next', () => ({
|
vi.mock('lucide-vue-next', () => ({
|
||||||
ChevronRight: { template: '<i />' },
|
ChevronRight: { template: '<i />' },
|
||||||
Heart: { template: '<i />' }
|
Heart: { template: '<i />' },
|
||||||
|
Plus: { template: '<i />' }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import NavMenu from '../NavMenu.vue';
|
import NavMenu from '../NavMenu.vue';
|
||||||
@@ -242,7 +279,9 @@ function mountComponent() {
|
|||||||
return mount(NavMenu, {
|
return mount(NavMenu, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
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.openExternalLink.mockClear();
|
||||||
mocks.getString.mockClear();
|
mocks.getString.mockClear();
|
||||||
mocks.setString.mockClear();
|
mocks.setString.mockClear();
|
||||||
|
mocks.loadDashboards.mockClear();
|
||||||
|
mocks.getDashboardNavDefinitions.mockClear();
|
||||||
mocks.currentRoute.value = { name: 'unknown', meta: {} };
|
mocks.currentRoute.value = { name: 'unknown', meta: {} };
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initializes theme and navigates to first route on mount', async () => {
|
it('initializes theme and navigates to first route on mount', async () => {
|
||||||
mountComponent();
|
mountComponent();
|
||||||
|
|
||||||
await Promise.resolve();
|
await vi.waitFor(() => {
|
||||||
await Promise.resolve();
|
expect(mocks.initThemeColor).toHaveBeenCalled();
|
||||||
|
expect(mocks.loadDashboards).toHaveBeenCalled();
|
||||||
|
expect(mocks.getString).toHaveBeenCalledWith(
|
||||||
|
'VRCX_customNavMenuLayoutList'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(mocks.initThemeColor).toHaveBeenCalled();
|
await vi.waitFor(() => {
|
||||||
expect(mocks.getString).toHaveBeenCalledWith('VRCX_customNavMenuLayoutList');
|
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' });
|
||||||
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs direct access action when direct-access menu is clicked', async () => {
|
it('runs direct access action when direct-access menu is clicked', async () => {
|
||||||
@@ -280,7 +326,9 @@ describe('NavMenu.vue', () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
const target = wrapper
|
const target = wrapper
|
||||||
.findAll('[data-testid="menu-btn"]')
|
.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();
|
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();
|
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;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
"update_available": "Update available",
|
"update_available": "Update available",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"mark_all_read": "Mark All as Read",
|
"mark_all_read": "Mark All as Read",
|
||||||
|
"edit_dashboard": "Edit Dashboard",
|
||||||
|
"delete_dashboard": "Delete Dashboard",
|
||||||
"custom_nav": {
|
"custom_nav": {
|
||||||
"header": "Customize Navigation",
|
"header": "Customize Navigation",
|
||||||
"dialog_title": "Customize Navigation Menu",
|
"dialog_title": "Customize Navigation Menu",
|
||||||
@@ -66,6 +68,8 @@
|
|||||||
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
|
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
|
||||||
"edit_folder": "Edit Folder",
|
"edit_folder": "Edit Folder",
|
||||||
"delete_folder": "Delete Folder",
|
"delete_folder": "Delete Folder",
|
||||||
|
"edit_dashboard": "Edit Dashboard",
|
||||||
|
"delete_dashboard": "Delete Dashboard",
|
||||||
"folder_empty": "This folder is empty",
|
"folder_empty": "This folder is empty",
|
||||||
"folder_drop_here": "Drag items here",
|
"folder_drop_here": "Drag items here",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
@@ -77,6 +81,39 @@
|
|||||||
"restore_default_confirm": "Restore navigation to its default order?"
|
"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": {
|
"side_panel": {
|
||||||
"search_placeholder": "Quick Search...",
|
"search_placeholder": "Quick Search...",
|
||||||
"search_no_results": "No results found",
|
"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 FriendList from './../views/FriendList/FriendList.vue';
|
||||||
import FriendLog from './../views/FriendLog/FriendLog.vue';
|
import FriendLog from './../views/FriendLog/FriendLog.vue';
|
||||||
import FriendsLocations from './../views/FriendsLocations/FriendsLocations.vue';
|
import FriendsLocations from './../views/FriendsLocations/FriendsLocations.vue';
|
||||||
|
import Dashboard from './../views/Dashboard/Dashboard.vue';
|
||||||
import Gallery from './../views/Tools/Gallery.vue';
|
import Gallery from './../views/Tools/Gallery.vue';
|
||||||
import GameLog from './../views/GameLog/GameLog.vue';
|
import GameLog from './../views/GameLog/GameLog.vue';
|
||||||
import Login from './../views/Login/Login.vue';
|
import Login from './../views/Login/Login.vue';
|
||||||
@@ -44,6 +45,13 @@ const routes = [
|
|||||||
{ path: 'game-log', name: 'game-log', component: GameLog },
|
{ path: 'game-log', name: 'game-log', component: GameLog },
|
||||||
{ path: 'player-list', name: 'player-list', component: PlayerList },
|
{ path: 'player-list', name: 'player-list', component: PlayerList },
|
||||||
{ path: 'search', name: 'search', component: Search },
|
{ path: 'search', name: 'search', component: Search },
|
||||||
|
{
|
||||||
|
path: 'dashboard/:id',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: Dashboard,
|
||||||
|
props: true,
|
||||||
|
meta: { navKey: 'dashboard' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'favorites/friends',
|
path: 'favorites/friends',
|
||||||
name: 'favorite-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 './ui';
|
||||||
export * from './accessType';
|
export * from './accessType';
|
||||||
export * from './tags';
|
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 { useAvatarProviderStore } from './avatarProvider';
|
||||||
import { useAvatarStore } from './avatar';
|
import { useAvatarStore } from './avatar';
|
||||||
import { useChartsStore } from './charts';
|
import { useChartsStore } from './charts';
|
||||||
|
import { useDashboardStore } from './dashboard';
|
||||||
import { useDiscordPresenceSettingsStore } from './settings/discordPresence';
|
import { useDiscordPresenceSettingsStore } from './settings/discordPresence';
|
||||||
import { useFavoriteStore } from './favorite';
|
import { useFavoriteStore } from './favorite';
|
||||||
import { useFeedStore } from './feed';
|
import { useFeedStore } from './feed';
|
||||||
@@ -164,6 +165,7 @@ export function createGlobalStores() {
|
|||||||
auth: useAuthStore(),
|
auth: useAuthStore(),
|
||||||
vrcStatus: useVrcStatusStore(),
|
vrcStatus: useVrcStatusStore(),
|
||||||
charts: useChartsStore(),
|
charts: useChartsStore(),
|
||||||
|
dashboard: useDashboardStore(),
|
||||||
modal: useModalStore(),
|
modal: useModalStore(),
|
||||||
globalSearch: useGlobalSearchStore()
|
globalSearch: useGlobalSearchStore()
|
||||||
};
|
};
|
||||||
@@ -189,6 +191,7 @@ export {
|
|||||||
usePhotonStore,
|
usePhotonStore,
|
||||||
useSearchStore,
|
useSearchStore,
|
||||||
useChartsStore,
|
useChartsStore,
|
||||||
|
useDashboardStore,
|
||||||
useAdvancedSettingsStore,
|
useAdvancedSettingsStore,
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useDiscordPresenceSettingsStore,
|
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 LaunchDialog from '../../components/dialogs/LaunchDialog.vue';
|
||||||
import LaunchOptionsDialog from '../Settings/dialogs/LaunchOptionsDialog.vue';
|
import LaunchOptionsDialog from '../Settings/dialogs/LaunchOptionsDialog.vue';
|
||||||
import MainDialogContainer from '../../components/dialogs/MainDialogContainer.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 PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue';
|
||||||
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
|
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
|
||||||
import Sidebar from '../Sidebar/Sidebar.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('../../../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/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/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('../../Sidebar/Sidebar.vue', () => ({ default: { template: '<div />' } }));
|
||||||
vi.mock('../../../components/StatusBar.vue', () => ({ default: { template: '<div />' } }));
|
vi.mock('../../../components/StatusBar.vue', () => ({ default: { template: '<div />' } }));
|
||||||
vi.mock('../../../components/dialogs/MainDialogContainer.vue', () => ({ default: { template: '<div />' } }));
|
vi.mock('../../../components/dialogs/MainDialogContainer.vue', () => ({ default: { template: '<div />' } }));
|
||||||
|
|||||||
Reference in New Issue
Block a user