mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-11 19:03:51 +02:00
795 lines
35 KiB
Vue
795 lines
35 KiB
Vue
<template>
|
|
<Sidebar side="left" variant="sidebar" collapsible="icon">
|
|
<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"
|
|
: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 -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 -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"
|
|
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 -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>
|
|
|
|
<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 { 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, normalizeHiddenKeys, sanitizeLayout } from './navMenuUtils';
|
|
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
|
|
import { openExternalLink } from '../shared/utils';
|
|
|
|
import configRepository from '../service/config';
|
|
|
|
const CustomNavDialog = defineAsyncComponent(() => import('./dialogs/CustomNavDialog.vue'));
|
|
|
|
const { t, locale } = useI18n();
|
|
const router = useRouter();
|
|
|
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
|
|
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 { 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, notifiedMenus.value));
|
|
}
|
|
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;
|
|
background-color: #ef4444;
|
|
border-radius: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
.notify-dot-not-collapsed {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 0;
|
|
width: 6px;
|
|
height: 6px;
|
|
background-color: #ef4444;
|
|
border-radius: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
@container (max-width: 250px) {
|
|
.nav-shortcut-hint {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|