diff --git a/.gitignore b/.gitignore index 02c3910a..4c317921 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ bun.lock AGENTS.md AI_GUIDE.md CLAUDE.md +.coverage/ \ No newline at end of file diff --git a/src/components/NavMenu.vue b/src/components/NavMenu.vue deleted file mode 100644 index b0d98926..00000000 --- a/src/components/NavMenu.vue +++ /dev/null @@ -1,833 +0,0 @@ - - - - - - - - - - - - - - - {{ - item.titleIsCustom ? item.title : t(item.title || '') - }} - - {{ isMac ? '⌘' : 'Ctrl' }} - D - - - - - - handleCollapsedDropdownOpenChange(item.index, value) - "> - - - - {{ - item.titleIsCustom ? item.title : t(item.title || '') - }} - - - - - handleCollapsedSubmenuSelect(event, entry, item.index) - "> - - {{ t(entry.label) }} - - - - - - - - - - {{ - item.titleIsCustom ? item.title : t(item.title || '') - }} - - - - - - - - - - {{ t(entry.label) }} - - - - - - - - - - - - - - - - {{ t('nav_menu.mark_all_read') }} - - - - {{ t('nav_menu.custom_nav.header') }} - - - - - - - - - - - - {{ t('nav_tooltip.help_support') }} - - - - - {{ t('nav_menu.whats_new') }} - - - {{ t('nav_menu.resources') }} - - {{ t('nav_menu.wiki') }} - - - {{ t('nav_menu.get_help') }} - - {{ t('nav_menu.github') }} - - - {{ t('nav_menu.discord') }} - - - - - - - - - {{ t('nav_tooltip.toggle_theme') }} - - - - - - - - - - - - {{ t('nav_tooltip.manage') }} - - - - - - - - VRCX - - - {{ version }} - - - - - {{ t('nav_menu.update_available') }} - - - - {{ t('nav_tooltip.settings') }} - - - - - {{ t('view.settings.appearance.appearance.theme_mode') }} - - - - {{ themeDisplayName(theme) }} - - - - - - - - - - - - - - - {{ t('view.settings.appearance.appearance.table_density') }} - - - - {{ - t('view.settings.appearance.appearance.table_density_comfortable') - }} - - - {{ - t('view.settings.appearance.appearance.table_density_standard') - }} - - - {{ - t('view.settings.appearance.appearance.table_density_compact') - }} - - - - - {{ t('nav_menu.custom_nav.header') }} - - - - - {{ t('dialog.user.actions.logout') }} - - - - - - - - - {{ t('nav_tooltip.collapse_menu') }} - - - - - - - - - - - - diff --git a/src/components/dialogs/CustomNavDialog.vue b/src/components/dialogs/CustomNavDialog.vue index 66b6e947..ea88232f 100644 --- a/src/components/dialogs/CustomNavDialog.vue +++ b/src/components/dialogs/CustomNavDialog.vue @@ -30,6 +30,8 @@ :drag-state="dragState" @edit-folder="openFolderEditor" @delete-folder="handleDeleteFolder" + @edit-dashboard="openDashboardEditor" + @delete-dashboard="handleDeleteDashboard" @hide="handleHideItem" @toggle="handleTreeToggle(item)" /> @@ -73,6 +75,9 @@ {{ t('nav_menu.custom_nav.new_folder') }} + + {{ t('dashboard.new_dashboard') }} + {{ t('nav_menu.custom_nav.restore_default') }} @@ -93,7 +98,13 @@ - {{ t('nav_menu.custom_nav.edit_folder') }} + + {{ + folderEditor.editorType === 'dashboard' + ? t('nav_menu.custom_nav.edit_dashboard') + : t('nav_menu.custom_nav.edit_folder') + }} + [] + }, + definitions: { + type: Array, + default: () => [] } }); - const emit = defineEmits(['update:visible', 'save']); + const emit = defineEmits(['update:visible', 'save', 'dashboard-created']); const { t } = useI18n(); + const dashboardStore = useDashboardStore(); + const modalStore = useModalStore(); const cloneLayout = (source) => { if (!Array.isArray(source)) return []; @@ -210,6 +229,7 @@ visible: false, isEditing: false, editingId: null, + editorType: 'folder', data: { id: '', name: '', icon: '' } }); @@ -243,49 +263,53 @@ const definitionsMap = computed(() => { const map = new Map(); - navDefinitions.forEach((def) => { + const source = props.definitions?.length ? props.definitions : navDefinitions; + source.forEach((def) => { if (def?.key) map.set(def.key, def); }); return map; }); const treeItems = computed(() => { - return localLayout.value.map((entry) => { - if (entry.type === 'folder') { - const children = (entry.items || []) - .map((key) => { - const def = definitionsMap.value.get(key); - if (!def) return null; - return { id: key, type: 'item', key, level: 1, parentId: entry.id }; - }) - .filter(Boolean); + return localLayout.value + .map((entry) => { + if (entry.type === 'folder') { + const children = (entry.items || []) + .map((key) => { + const def = definitionsMap.value.get(key); + if (!def) return null; + return { id: key, type: 'item', key, level: 1, parentId: entry.id }; + }) + .filter(Boolean); - const folderChildren = children.length - ? children - : [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }]; + const folderChildren = children.length + ? children + : [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }]; - return { - id: entry.id, - type: 'folder', - name: entry.name, - icon: entry.icon, - level: 0, - children: folderChildren - }; - } - return { id: entry.key, type: 'item', key: entry.key, level: 0 }; - }); + return { + id: entry.id, + type: 'folder', + name: entry.name, + icon: entry.icon, + level: 0, + children: folderChildren + }; + } + if (!definitionsMap.value.has(entry.key)) return null; + return { id: entry.key, type: 'item', key: entry.key, level: 0 }; + }) + .filter(Boolean); }); const expandedKeys = ref([]); const hiddenItems = computed(() => - navDefinitions + (props.definitions?.length ? props.definitions : navDefinitions) .filter((def) => hiddenKeySet.value.has(def.key)) .map((def) => ({ key: def.key, icon: def.icon, - label: t(def.labelKey) + label: def.isDashboard ? def.labelKey : t(def.labelKey) })) ); @@ -646,13 +670,31 @@ if (!entry) return; folderEditor.isEditing = true; folderEditor.editingId = folderId; + folderEditor.editorType = 'folder'; folderEditor.data = { id: entry.id, name: entry.name, icon: entry.icon }; folderEditor.visible = true; }; + const openDashboardEditor = (dashboardKey) => { + const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, ''); + const dashboard = dashboardStore.getDashboard(dashboardId); + if (!dashboard) return; + + folderEditor.isEditing = true; + folderEditor.editingId = dashboardKey; + folderEditor.editorType = 'dashboard'; + folderEditor.data = { + id: dashboardKey, + name: dashboard.name, + icon: dashboard.icon || '' + }; + folderEditor.visible = true; + }; + const handleAddFolder = () => { folderEditor.isEditing = false; folderEditor.editingId = null; + folderEditor.editorType = 'folder'; folderEditor.data = { id: createFolderId(), name: '', @@ -661,10 +703,27 @@ folderEditor.visible = true; }; - const handleFolderEditorSave = () => { + const handleAddDashboard = async () => { + const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name')); + dashboardStore.setEditingDashboardId(dashboard.id); + localLayout.value.push({ + type: 'item', + key: `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}` + }); + localLayout.value = [...localLayout.value]; + emit('dashboard-created', dashboard.id, cloneLayout(localLayout.value), [...hiddenKeySet.value]); + }; + + const handleFolderEditorSave = async () => { if (!folderEditor.data.name?.trim()) return; - if (folderEditor.isEditing) { + if (folderEditor.editorType === 'dashboard') { + const dashboardId = String(folderEditor.editingId || '').replace(DASHBOARD_NAV_KEY_PREFIX, ''); + await dashboardStore.updateDashboard(dashboardId, { + name: folderEditor.data.name.trim(), + icon: folderEditor.data.icon?.trim() || DEFAULT_DASHBOARD_ICON + }); + } else if (folderEditor.isEditing) { const entry = localLayout.value.find((e) => e.type === 'folder' && e.id === folderEditor.editingId); if (entry) { entry.name = folderEditor.data.name.trim(); @@ -689,6 +748,43 @@ folderEditor.visible = false; }; + const removeFromLayout = (key) => { + let removed = false; + for (let i = 0; i < localLayout.value.length; i++) { + const entry = localLayout.value[i]; + if (entry.type === 'item' && entry.key === key) { + localLayout.value.splice(i, 1); + removed = true; + break; + } + if (entry.type === 'folder') { + const idx = entry.items?.indexOf(key); + if (idx !== undefined && idx >= 0) { + entry.items.splice(idx, 1); + removed = true; + } + } + } + if (removed) { + hiddenKeySet.value.delete(key); + hiddenPlacement.value.delete(key); + localLayout.value = [...localLayout.value]; + } + }; + + const handleDeleteDashboard = async (dashboardKey) => { + const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, ''); + const { ok } = await modalStore.confirm({ + title: t('dashboard.confirmations.delete_title'), + description: t('dashboard.confirmations.delete_description') + }); + if (!ok) { + return; + } + await dashboardStore.deleteDashboard(dashboardId); + removeFromLayout(dashboardKey); + }; + const handleSave = () => { const cleanedLayout = localLayout.value.filter( (entry) => !(entry.type === 'folder' && (!entry.items || entry.items.length === 0)) diff --git a/src/components/dialogs/SortableTreeNode.vue b/src/components/dialogs/SortableTreeNode.vue index 7f22ba4d..1aae50af 100644 --- a/src/components/dialogs/SortableTreeNode.vue +++ b/src/components/dialogs/SortableTreeNode.vue @@ -19,7 +19,7 @@ dragState: { type: Object, default: () => ({}) } }); - const emit = defineEmits(['editFolder', 'deleteFolder', 'hide', 'toggle']); + const emit = defineEmits(['editFolder', 'deleteFolder', 'editDashboard', 'deleteDashboard', 'hide', 'toggle']); const { t } = useI18n(); @@ -27,6 +27,9 @@ const nodeValue = computed(() => props.item.value); const isFolder = computed(() => nodeValue.value?.type === 'folder'); + const isDashboard = computed(() => { + return !isFolder.value && nodeValue.value?.key?.startsWith('dashboard-'); + }); const hasChildren = computed(() => props.item.hasChildren); const level = computed(() => nodeValue.value?.level ?? 0); const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key)); @@ -43,7 +46,10 @@ return nodeValue.value.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'); } const def = props.definitionsMap.get(nodeValue.value?.key); - return def ? t(def.labelKey) : nodeValue.value?.key || ''; + if (!def) { + return nodeValue.value?.key || ''; + } + return def.isDashboard ? def.labelKey : t(def.labelKey); }); const displayIcon = computed(() => { @@ -111,6 +117,14 @@ {{ t('nav_menu.custom_nav.delete_folder') }} + + + {{ t('nav_menu.custom_nav.edit_dashboard') }} + + + {{ t('nav_menu.custom_nav.delete_dashboard') }} + + {{ t('nav_menu.custom_nav.hide') }} diff --git a/src/components/dialogs/__tests__/CustomNavDialog.test.js b/src/components/dialogs/__tests__/CustomNavDialog.test.js index 97829c7d..b90cb94e 100644 --- a/src/components/dialogs/__tests__/CustomNavDialog.test.js +++ b/src/components/dialogs/__tests__/CustomNavDialog.test.js @@ -3,6 +3,18 @@ import { mount } from '@vue/test-utils'; vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) })); vi.mock('@/shared/utils/common', () => ({ openExternalLink: vi.fn() })); +vi.mock('../../../stores', () => ({ + useDashboardStore: () => ({ + createDashboard: vi.fn(async () => ({ id: 'dashboard-1', name: 'Dashboard', icon: 'ri-dashboard-line' })), + getDashboard: vi.fn(() => ({ id: 'dashboard-1', name: 'Dashboard', icon: 'ri-dashboard-line' })), + updateDashboard: vi.fn(async () => {}), + deleteDashboard: vi.fn(async () => {}), + setEditingDashboardId: vi.fn() + }), + useModalStore: () => ({ + confirm: vi.fn(async () => ({ ok: true })) + }) +})); vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '' }, DialogContent: { template: '' }, diff --git a/src/components/nav-menu/NavMenu.vue b/src/components/nav-menu/NavMenu.vue new file mode 100644 index 00000000..09540d63 --- /dev/null +++ b/src/components/nav-menu/NavMenu.vue @@ -0,0 +1,443 @@ + + + + + + + + {{ t('dashboard.new_dashboard') }} + + + + + + + + + + + + + + + + + + + + {{ + item.titleIsCustom ? item.title : t(item.title || '') + }} + + {{ isMac ? '⌘' : 'Ctrl' }} + D + + + + + + {{ t('nav_menu.mark_all_read') }} + + + + + {{ t('nav_menu.edit_dashboard') }} + + + {{ t('nav_menu.delete_dashboard') }} + + + + + {{ t('dashboard.new_dashboard') }} + + + {{ t('nav_menu.custom_nav.header') }} + + + + + + + + + + + + + + + {{ t('nav_menu.mark_all_read') }} + + + + {{ t('dashboard.new_dashboard') }} + + + {{ t('nav_menu.custom_nav.header') }} + + + + + + + + + + + + + diff --git a/src/components/nav-menu/NavMenuFolderItem.vue b/src/components/nav-menu/NavMenuFolderItem.vue new file mode 100644 index 00000000..2da4ab3d --- /dev/null +++ b/src/components/nav-menu/NavMenuFolderItem.vue @@ -0,0 +1,228 @@ + + + + + + emit('collapsed-dropdown-open-change', item.index, value)"> + + + + {{ + item.titleIsCustom ? item.title : t(item.title || '') + }} + + + + emit('collapsed-submenu-select', event, entry)"> + + {{ entry.label }} + {{ t(entry.label) }} + + + + + + + + + + {{ + item.titleIsCustom ? item.title : t(item.title || '') + }} + + + + + + + + + + + + {{ entry.label }} + {{ t(entry.label) }} + + + + + {{ t('nav_menu.mark_all_read') }} + + + + + {{ t('nav_menu.edit_dashboard') }} + + + {{ t('nav_menu.delete_dashboard') }} + + + + + {{ t('dashboard.new_dashboard') }} + + + {{ t('nav_menu.custom_nav.header') }} + + + + + + + + + + + + + {{ t('nav_menu.mark_all_read') }} + + + + {{ t('dashboard.new_dashboard') }} + + + {{ t('nav_menu.custom_nav.header') }} + + + + + + + + + diff --git a/src/components/nav-menu/NavMenuFooter.vue b/src/components/nav-menu/NavMenuFooter.vue new file mode 100644 index 00000000..8e756aa2 --- /dev/null +++ b/src/components/nav-menu/NavMenuFooter.vue @@ -0,0 +1,270 @@ + + + + + + + + + {{ t('nav_tooltip.help_support') }} + + + + + {{ t('nav_menu.whats_new') }} + + + {{ t('nav_menu.resources') }} + + {{ t('nav_menu.wiki') }} + + + {{ t('nav_menu.get_help') }} + + {{ t('nav_menu.github') }} + + + {{ t('nav_menu.discord') }} + + + + + + + + + {{ t('nav_tooltip.toggle_theme') }} + + + + + + + + + + + + {{ t('nav_tooltip.manage') }} + + + + + + + + VRCX + + + {{ version }} + + + + + {{ t('nav_menu.update_available') }} + + + + {{ t('nav_tooltip.settings') }} + + + + + {{ t('view.settings.appearance.appearance.theme_mode') }} + + + + {{ themeDisplayName(theme) }} + + + + + + + + + + + + + + + {{ t('view.settings.appearance.appearance.table_density') }} + + + + {{ + t('view.settings.appearance.appearance.table_density_comfortable') + }} + + + {{ t('view.settings.appearance.appearance.table_density_standard') }} + + + {{ t('view.settings.appearance.appearance.table_density_compact') }} + + + + + {{ t('nav_menu.custom_nav.header') }} + + + + + {{ t('dialog.user.actions.logout') }} + + + + + + + + + {{ t('nav_tooltip.collapse_menu') }} + + + + + + + diff --git a/src/components/__tests__/NavMenu.test.js b/src/components/nav-menu/__tests__/NavMenu.test.js similarity index 72% rename from src/components/__tests__/NavMenu.test.js rename to src/components/nav-menu/__tests__/NavMenu.test.js index f179e9eb..2680c842 100644 --- a/src/components/__tests__/NavMenu.test.js +++ b/src/components/nav-menu/__tests__/NavMenu.test.js @@ -23,7 +23,13 @@ const mocks = vi.hoisted(() => ({ tableDensity: { value: 'standard' }, isDarkMode: { value: false }, isNavCollapsed: { value: false }, - currentRoute: { value: { name: 'unknown', meta: {} } } + currentRoute: { value: { name: 'unknown', meta: {} } }, + dashboards: { value: [] }, + dashboardNavKeys: new Set(), + loadDashboards: vi.fn(() => Promise.resolve()), + getDashboardNavDefinitions: vi.fn(() => []), + createDashboard: vi.fn(() => Promise.resolve({ id: 'dashboard-1' })), + setEditingDashboardId: vi.fn() })); vi.mock('pinia', async (importOriginal) => { @@ -41,15 +47,15 @@ vi.mock('vue-i18n', () => ({ }) })); -vi.mock('../../views/Feed/Feed.vue', () => ({ +vi.mock('../../../views/Feed/Feed.vue', () => ({ default: { template: '' } })); -vi.mock('../../views/Feed/columns.jsx', () => ({ +vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] })); -vi.mock('../../plugins/router', () => ({ +vi.mock('../../../plugins/router', () => ({ router: { beforeEach: vi.fn(), push: vi.fn(), @@ -60,11 +66,11 @@ vi.mock('../../plugins/router', () => ({ initRouter: vi.fn() })); -vi.mock('../../plugins/interopApi', () => ({ +vi.mock('../../../plugins/interopApi', () => ({ initInteropApi: vi.fn() })); -vi.mock('../../services/database', () => ({ +vi.mock('../../../services/database', () => ({ database: new Proxy( {}, { @@ -76,11 +82,11 @@ vi.mock('../../services/database', () => ({ ) })); -vi.mock('../../services/jsonStorage', () => ({ +vi.mock('../../../services/jsonStorage', () => ({ default: vi.fn() })); -vi.mock('../../services/watchState', () => ({ +vi.mock('../../../services/watchState', () => ({ watchState: { isLoggedIn: false } })); @@ -91,7 +97,7 @@ vi.mock('vue-router', () => ({ }) })); -vi.mock('../../stores', () => ({ +vi.mock('../../../stores', () => ({ useVRCXUpdaterStore: () => ({ pendingVRCXUpdate: mocks.pendingVRCXUpdate, pendingVRCXInstall: mocks.pendingVRCXInstall, @@ -118,17 +124,30 @@ vi.mock('../../stores', () => ({ toggleThemeMode: (...args) => mocks.toggleThemeMode(...args), setTableDensity: vi.fn(), toggleNavCollapsed: (...args) => mocks.toggleNavCollapsed(...args) + }), + useDashboardStore: () => ({ + dashboards: mocks.dashboards, + dashboardNavKeys: mocks.dashboardNavKeys, + loadDashboards: (...args) => mocks.loadDashboards(...args), + getDashboardNavDefinitions: (...args) => + mocks.getDashboardNavDefinitions(...args), + createDashboard: (...args) => mocks.createDashboard(...args), + setEditingDashboardId: (...args) => mocks.setEditingDashboardId(...args) + }), + useModalStore: () => ({ + confirm: vi.fn(() => Promise.resolve({ ok: false })) }) })); -vi.mock('../../services/config', () => ({ +vi.mock('../../../services/config', () => ({ default: { getString: (...args) => mocks.getString(...args), setString: (...args) => mocks.setString(...args) } })); -vi.mock('../../shared/constants', () => ({ +vi.mock('../../../shared/constants', () => ({ + DASHBOARD_NAV_KEY_PREFIX: 'dashboard-', THEME_CONFIG: { system: { name: 'System' }, light: { name: 'Light' }, @@ -155,20 +174,22 @@ vi.mock('../../shared/constants', () => ({ ] })); -vi.mock('./navMenuUtils', () => ({ +vi.mock('../navMenuUtils', () => ({ getFirstNavRoute: () => 'feed', isEntryNotified: () => false, normalizeHiddenKeys: (keys) => keys || [], sanitizeLayout: (layout) => layout })); -vi.mock('../../shared/utils', () => ({ +vi.mock('../../../shared/utils', () => ({ openExternalLink: (...args) => mocks.openExternalLink(...args) })); vi.mock('@/shared/utils/base/ui', () => ({ useThemeColor: () => ({ - themeColors: { value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }] }, + themeColors: { + value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }] + }, currentThemeColor: { value: 'blue' }, isApplyingThemeColor: { value: false }, applyThemeColor: (...args) => mocks.applyThemeColor(...args), @@ -178,6 +199,7 @@ vi.mock('@/shared/utils/base/ui', () => ({ vi.mock('@/components/ui/sidebar', () => ({ Sidebar: { template: '' }, + SidebarHeader: { template: '' }, SidebarContent: { template: '' }, SidebarFooter: { template: '' }, SidebarGroup: { template: '' }, @@ -188,11 +210,13 @@ vi.mock('@/components/ui/sidebar', () => ({ SidebarMenuSubItem: { template: '' }, SidebarMenuButton: { emits: ['click'], - template: '' + template: + '' }, SidebarMenuSubButton: { emits: ['click'], - template: '' + template: + '' } })); @@ -200,20 +224,32 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: { template: '' }, DropdownMenuTrigger: { template: '' }, DropdownMenuContent: { template: '' }, - DropdownMenuItem: { emits: ['click', 'select'], template: '' }, + DropdownMenuItem: { + emits: ['click', 'select'], + template: + '' + }, DropdownMenuSeparator: { template: '' }, DropdownMenuLabel: { template: '' }, DropdownMenuSub: { template: '' }, DropdownMenuSubTrigger: { template: '' }, DropdownMenuSubContent: { template: '' }, - DropdownMenuCheckboxItem: { emits: ['select'], template: '' } + DropdownMenuCheckboxItem: { + emits: ['select'], + template: + '' + } })); vi.mock('@/components/ui/context-menu', () => ({ ContextMenu: { template: '' }, ContextMenuTrigger: { template: '' }, ContextMenuContent: { template: '' }, - ContextMenuItem: { emits: ['click'], template: '' }, + ContextMenuItem: { + emits: ['click'], + template: + '' + }, ContextMenuSeparator: { template: '' } })); @@ -233,7 +269,8 @@ vi.mock('@/components/ui/tooltip', () => ({ vi.mock('lucide-vue-next', () => ({ ChevronRight: { template: '' }, - Heart: { template: '' } + Heart: { template: '' }, + Plus: { template: '' } })); import NavMenu from '../NavMenu.vue'; @@ -242,7 +279,9 @@ function mountComponent() { return mount(NavMenu, { global: { stubs: { - CustomNavDialog: { template: '' } + CustomNavDialog: { + template: '' + } } } }); @@ -261,18 +300,25 @@ describe('NavMenu.vue', () => { mocks.openExternalLink.mockClear(); mocks.getString.mockClear(); mocks.setString.mockClear(); + mocks.loadDashboards.mockClear(); + mocks.getDashboardNavDefinitions.mockClear(); mocks.currentRoute.value = { name: 'unknown', meta: {} }; }); it('initializes theme and navigates to first route on mount', async () => { mountComponent(); - await Promise.resolve(); - await Promise.resolve(); + await vi.waitFor(() => { + expect(mocks.initThemeColor).toHaveBeenCalled(); + expect(mocks.loadDashboards).toHaveBeenCalled(); + expect(mocks.getString).toHaveBeenCalledWith( + 'VRCX_customNavMenuLayoutList' + ); + }); - expect(mocks.initThemeColor).toHaveBeenCalled(); - expect(mocks.getString).toHaveBeenCalledWith('VRCX_customNavMenuLayoutList'); - expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' }); + await vi.waitFor(() => { + expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' }); + }); }); it('runs direct access action when direct-access menu is clicked', async () => { @@ -280,7 +326,9 @@ describe('NavMenu.vue', () => { await vi.waitFor(() => { const target = wrapper .findAll('[data-testid="menu-btn"]') - .find((node) => node.text().includes('nav_tooltip.direct_access')); + .find((node) => + node.text().includes('nav_tooltip.direct_access') + ); expect(target).toBeTruthy(); }); diff --git a/src/components/nav-menu/__tests__/NavMenuFolderItem.test.js b/src/components/nav-menu/__tests__/NavMenuFolderItem.test.js new file mode 100644 index 00000000..39c379d7 --- /dev/null +++ b/src/components/nav-menu/__tests__/NavMenuFolderItem.test.js @@ -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: '' } +})); + +vi.mock('@/components/ui/sidebar', () => ({ + SidebarMenuItem: { template: '' }, + SidebarMenuButton: { template: '' }, + SidebarMenuSub: { template: '' }, + SidebarMenuSubItem: { template: '' }, + SidebarMenuSubButton: { + emits: ['click'], + template: '' + } +})); + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: { template: '' }, + CollapsibleTrigger: { template: '' }, + CollapsibleContent: { template: '' } +})); + +vi.mock('@/components/ui/context-menu', () => ({ + ContextMenu: { template: '' }, + ContextMenuTrigger: { template: '' }, + ContextMenuContent: { template: '' }, + ContextMenuItem: { emits: ['click'], template: '' }, + ContextMenuSeparator: { template: '' } +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: { + emits: ['update:open'], + template: '' + }, + DropdownMenuTrigger: { template: '' }, + DropdownMenuContent: { template: '' }, + DropdownMenuItem: { emits: ['select'], template: '' } +})); + +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(); + }); +}); diff --git a/src/components/nav-menu/__tests__/NavMenuFooter.test.js b/src/components/nav-menu/__tests__/NavMenuFooter.test.js new file mode 100644 index 00000000..020125ad --- /dev/null +++ b/src/components/nav-menu/__tests__/NavMenuFooter.test.js @@ -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: '' } +})); + +vi.mock('@/components/ui/tooltip', () => ({ + TooltipWrapper: { template: '' } +})); + +vi.mock('@/components/ui/sidebar', () => ({ + SidebarFooter: { template: '' }, + SidebarMenu: { template: '' }, + SidebarMenuItem: { template: '' }, + SidebarMenuButton: { + emits: ['click'], + template: '' + } +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: { template: '' }, + DropdownMenuTrigger: { template: '' }, + DropdownMenuContent: { template: '' }, + DropdownMenuItem: { + emits: ['click', 'select'], + template: '' + }, + DropdownMenuLabel: { template: '' }, + DropdownMenuSeparator: { template: '' }, + DropdownMenuSub: { template: '' }, + DropdownMenuSubTrigger: { template: '' }, + DropdownMenuSubContent: { template: '' }, + DropdownMenuCheckboxItem: { + emits: ['select'], + template: '' + } +})); + +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); + }); +}); diff --git a/src/components/__tests__/navMenuUtils.test.js b/src/components/nav-menu/__tests__/navMenuUtils.test.js similarity index 100% rename from src/components/__tests__/navMenuUtils.test.js rename to src/components/nav-menu/__tests__/navMenuUtils.test.js diff --git a/src/components/nav-menu/composables/__tests__/useNavLayout.test.js b/src/components/nav-menu/composables/__tests__/useNavLayout.test.js new file mode 100644 index 00000000..64e2aa25 --- /dev/null +++ b/src/components/nav-menu/composables/__tests__/useNavLayout.test.js @@ -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(); + }); +}); diff --git a/src/components/nav-menu/composables/__tests__/useNavTheme.test.js b/src/components/nav-menu/composables/__tests__/useNavTheme.test.js new file mode 100644 index 00000000..c5459c29 --- /dev/null +++ b/src/components/nav-menu/composables/__tests__/useNavTheme.test.js @@ -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'); + }); +}); diff --git a/src/components/nav-menu/composables/useNavLayout.js b/src/components/nav-menu/composables/useNavLayout.js new file mode 100644 index 00000000..150e39ff --- /dev/null +++ b/src/components/nav-menu/composables/useNavLayout.js @@ -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 + }; +} diff --git a/src/components/nav-menu/composables/useNavTheme.js b/src/components/nav-menu/composables/useNavTheme.js new file mode 100644 index 00000000..40014912 --- /dev/null +++ b/src/components/nav-menu/composables/useNavTheme.js @@ -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 + }; +} diff --git a/src/components/navMenuUtils.js b/src/components/nav-menu/navMenuUtils.js similarity index 93% rename from src/components/navMenuUtils.js rename to src/components/nav-menu/navMenuUtils.js index 3b7f10b4..5cea7b2b 100644 --- a/src/components/navMenuUtils.js +++ b/src/components/nav-menu/navMenuUtils.js @@ -127,6 +127,15 @@ export function sanitizeLayout( appendChartsFolder(); } + // Ensure direct-access is always the last item + const directAccessIdx = normalized.findIndex( + (entry) => entry.type === 'item' && entry.key === 'direct-access' + ); + if (directAccessIdx !== -1 && directAccessIdx !== normalized.length - 1) { + const [directAccessEntry] = normalized.splice(directAccessIdx, 1); + normalized.push(directAccessEntry); + } + return normalized; } diff --git a/src/localization/en.json b/src/localization/en.json index c6f0352e..c4645be0 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -58,6 +58,8 @@ "update_available": "Update available", "update": "Update", "mark_all_read": "Mark All as Read", + "edit_dashboard": "Edit Dashboard", + "delete_dashboard": "Delete Dashboard", "custom_nav": { "header": "Customize Navigation", "dialog_title": "Customize Navigation Menu", @@ -66,6 +68,8 @@ "folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)", "edit_folder": "Edit Folder", "delete_folder": "Delete Folder", + "edit_dashboard": "Edit Dashboard", + "delete_dashboard": "Delete Dashboard", "folder_empty": "This folder is empty", "folder_drop_here": "Drag items here", "hide": "Hide", @@ -77,6 +81,39 @@ "restore_default_confirm": "Restore navigation to its default order?" } }, + "dashboard": { + "default_name": "Dashboard", + "new_dashboard": "New Dashboard", + "empty": "This dashboard is empty", + "actions": { + "start_editing": "Start Editing", + "add_row": "Add Row", + "add_full_row": "Add Full Row", + "add_split_row": "Add Split Row", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete" + }, + "toolbar": { + "editing": "Editing Dashboard", + "name_placeholder": "Dashboard Name", + "icon_placeholder": "Icon Class (Optional)" + }, + "panel": { + "not_selected": "No Panel Selected", + "replace": "Replace Panel", + "select": "Select Panel", + "not_configured": "Panel Not Configured" + }, + "selector": { + "title": "Select Panel Content", + "clear": "Clear Panel" + }, + "confirmations": { + "delete_title": "Delete Dashboard", + "delete_description": "Are you sure you want to delete this dashboard? This action cannot be undone." + } + }, "side_panel": { "search_placeholder": "Quick Search...", "search_no_results": "No results found", diff --git a/src/plugins/router.js b/src/plugins/router.js index a73a2a7f..b517a522 100644 --- a/src/plugins/router.js +++ b/src/plugins/router.js @@ -9,6 +9,7 @@ import Feed from './../views/Feed/Feed.vue'; import FriendList from './../views/FriendList/FriendList.vue'; import FriendLog from './../views/FriendLog/FriendLog.vue'; import FriendsLocations from './../views/FriendsLocations/FriendsLocations.vue'; +import Dashboard from './../views/Dashboard/Dashboard.vue'; import Gallery from './../views/Tools/Gallery.vue'; import GameLog from './../views/GameLog/GameLog.vue'; import Login from './../views/Login/Login.vue'; @@ -44,6 +45,13 @@ const routes = [ { path: 'game-log', name: 'game-log', component: GameLog }, { path: 'player-list', name: 'player-list', component: PlayerList }, { path: 'search', name: 'search', component: Search }, + { + path: 'dashboard/:id', + name: 'dashboard', + component: Dashboard, + props: true, + meta: { navKey: 'dashboard' } + }, { path: 'favorites/friends', name: 'favorite-friends', diff --git a/src/shared/constants/dashboard.js b/src/shared/constants/dashboard.js new file mode 100644 index 00000000..a754c7fa --- /dev/null +++ b/src/shared/constants/dashboard.js @@ -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'; diff --git a/src/shared/constants/index.js b/src/shared/constants/index.js index 846857b2..e061f805 100644 --- a/src/shared/constants/index.js +++ b/src/shared/constants/index.js @@ -13,3 +13,4 @@ export * from './link'; export * from './ui'; export * from './accessType'; export * from './tags'; +export * from './dashboard'; diff --git a/src/stores/dashboard.js b/src/stores/dashboard.js new file mode 100644 index 00000000..7e6d2e62 --- /dev/null +++ b/src/stores/dashboard.js @@ -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 + }; +}); diff --git a/src/stores/index.js b/src/stores/index.js index f62aee40..e3314e5a 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -8,6 +8,7 @@ import { useAuthStore } from './auth'; import { useAvatarProviderStore } from './avatarProvider'; import { useAvatarStore } from './avatar'; import { useChartsStore } from './charts'; +import { useDashboardStore } from './dashboard'; import { useDiscordPresenceSettingsStore } from './settings/discordPresence'; import { useFavoriteStore } from './favorite'; import { useFeedStore } from './feed'; @@ -164,6 +165,7 @@ export function createGlobalStores() { auth: useAuthStore(), vrcStatus: useVrcStatusStore(), charts: useChartsStore(), + dashboard: useDashboardStore(), modal: useModalStore(), globalSearch: useGlobalSearchStore() }; @@ -189,6 +191,7 @@ export { usePhotonStore, useSearchStore, useChartsStore, + useDashboardStore, useAdvancedSettingsStore, useAppearanceSettingsStore, useDiscordPresenceSettingsStore, diff --git a/src/views/Dashboard/Dashboard.vue b/src/views/Dashboard/Dashboard.vue new file mode 100644 index 00000000..f44d888a --- /dev/null +++ b/src/views/Dashboard/Dashboard.vue @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + {{ t('dashboard.actions.add_row') }}: + + + + + + + + + + + + + + + {{ t('dashboard.empty') }} + {{ t('dashboard.actions.start_editing') }} + + + + + + + diff --git a/src/views/Dashboard/components/DashboardEditToolbar.vue b/src/views/Dashboard/components/DashboardEditToolbar.vue new file mode 100644 index 00000000..b5b7754e --- /dev/null +++ b/src/views/Dashboard/components/DashboardEditToolbar.vue @@ -0,0 +1,31 @@ + + + {{ t('dashboard.toolbar.editing') }} + + + {{ t('dashboard.actions.cancel') }} + {{ t('dashboard.actions.delete') }} + + {{ t('dashboard.actions.save') }} + + + + diff --git a/src/views/Dashboard/components/DashboardPanel.vue b/src/views/Dashboard/components/DashboardPanel.vue new file mode 100644 index 00000000..319a3b19 --- /dev/null +++ b/src/views/Dashboard/components/DashboardPanel.vue @@ -0,0 +1,89 @@ + + + + + + + {{ panelLabel || t('dashboard.panel.not_selected') }} + + + {{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }} + + + + + + + + + + + + {{ t('dashboard.panel.not_configured') }} + + + + + + + diff --git a/src/views/Dashboard/components/DashboardRow.vue b/src/views/Dashboard/components/DashboardRow.vue new file mode 100644 index 00000000..22f7f40f --- /dev/null +++ b/src/views/Dashboard/components/DashboardRow.vue @@ -0,0 +1,69 @@ + + + + emit('update-panel', rowIndex, panelIndex, key)" /> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/Dashboard/components/PanelSelector.vue b/src/views/Dashboard/components/PanelSelector.vue new file mode 100644 index 00000000..dc27f11b --- /dev/null +++ b/src/views/Dashboard/components/PanelSelector.vue @@ -0,0 +1,52 @@ + + !value && emit('close')"> + + + {{ t('dashboard.selector.title') }} + + + + + + {{ t(option.labelKey) }} + + + + + {{ t('dashboard.selector.clear') }} + {{ t('dashboard.actions.cancel') }} + + + + + + diff --git a/src/views/Dashboard/components/panelRegistry.js b/src/views/Dashboard/components/panelRegistry.js new file mode 100644 index 00000000..872c1f6f --- /dev/null +++ b/src/views/Dashboard/components/panelRegistry.js @@ -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 +}; diff --git a/src/views/Layout/MainLayout.vue b/src/views/Layout/MainLayout.vue index 4780726e..3f374e77 100644 --- a/src/views/Layout/MainLayout.vue +++ b/src/views/Layout/MainLayout.vue @@ -108,7 +108,7 @@ import LaunchDialog from '../../components/dialogs/LaunchDialog.vue'; import LaunchOptionsDialog from '../Settings/dialogs/LaunchOptionsDialog.vue'; import MainDialogContainer from '../../components/dialogs/MainDialogContainer.vue'; - import NavMenu from '../../components/NavMenu.vue'; + import NavMenu from '../../components/nav-menu/NavMenu.vue'; import PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue'; import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue'; import Sidebar from '../Sidebar/Sidebar.vue'; diff --git a/src/views/Layout/__tests__/MainLayout.test.js b/src/views/Layout/__tests__/MainLayout.test.js index 17ae6cc3..9539a360 100644 --- a/src/views/Layout/__tests__/MainLayout.test.js +++ b/src/views/Layout/__tests__/MainLayout.test.js @@ -11,7 +11,7 @@ vi.mock('../../../stores', () => ({ useAppearanceSettingsStore: () => ({ navWidt vi.mock('../../../composables/useMainLayoutResizable', () => ({ useMainLayoutResizable: () => ({ asideDefaultSize: 30, asideMinSize: 0, asideMaxPx: 480, mainDefaultSize: 70, handleLayout: vi.fn(), isAsideCollapsed: () => false, isAsideCollapsedStatic: false, isSideBarTabShow: ref(true) }) })); vi.mock('../../../components/ui/resizable', () => ({ ResizablePanelGroup: { template: '' }, ResizablePanel: { template: '' }, ResizableHandle: { template: '' } })); vi.mock('../../../components/ui/sidebar', () => ({ SidebarProvider: { template: '' }, SidebarInset: { template: '' } })); -vi.mock('../../../components/NavMenu.vue', () => ({ default: { template: '' } })); +vi.mock('../../../components/nav-menu/NavMenu.vue', () => ({ default: { template: '' } })); vi.mock('../../Sidebar/Sidebar.vue', () => ({ default: { template: '' } })); vi.mock('../../../components/StatusBar.vue', () => ({ default: { template: '' } })); vi.mock('../../../components/dialogs/MainDialogContainer.vue', () => ({ default: { template: '' } }));
{{ t('dashboard.empty') }}