diff --git a/src/components/dialogs/CustomNavDialog.vue b/src/components/dialogs/CustomNavDialog.vue index 32151d76..3ae26602 100644 --- a/src/components/dialogs/CustomNavDialog.vue +++ b/src/components/dialogs/CustomNavDialog.vue @@ -1,6 +1,6 @@ diff --git a/src/components/nav-menu/NavMenu.vue b/src/components/nav-menu/NavMenu.vue index 8b6e89d4..fa88cda3 100644 --- a/src/components/nav-menu/NavMenu.vue +++ b/src/components/nav-menu/NavMenu.vue @@ -66,9 +66,12 @@ - - {{ t('dashboard.new_dashboard') }} + + {{ t('nav_menu.custom_nav.unpin_from_nav') }} + {{ t('nav_menu.custom_nav.header') }} @@ -86,13 +89,14 @@ :is-entry-notified="isEntryNotified" :is-nav-item-notified="isNavItemNotified" :is-dashboard-item="isDashboardItem" + :is-tool-item="isToolItem" @collapsed-dropdown-open-change="handleCollapsedDropdownOpenChange" @collapsed-submenu-select="handleCollapsedSubmenuSelect" @submenu-click="handleSubmenuClick" @clear-notifications="clearAllNotifications" @edit-dashboard="handleEditDashboard" @delete-dashboard="handleDeleteDashboard" - @create-dashboard="handleQuickCreateDashboard" + @unpin-tool="handleUnpinToolItem" @open-custom-nav="handleOpenCustomNavDialog" /> @@ -147,6 +151,7 @@ v-model:visible="customNavDialogVisible" :layout="navLayout" :hidden-keys="navHiddenKeys" + :default-hidden-keys="defaultHiddenKeys" :default-layout="defaultNavLayout" :definitions="allNavDefinitions" @save="handleCustomNavSave" @@ -163,6 +168,8 @@ import { useNavLayout } from './composables/useNavLayout'; import { useNavTheme } from './composables/useNavTheme'; + import { useToolActions } from '../../composables/useToolActions'; + import { useToolNavPinning } from '../../composables/useToolNavPinning'; import { Kbd } from '@/components/ui/kbd'; import { ContextMenu, @@ -216,6 +223,8 @@ const { clearAllNotifications } = uiStore; const { directAccessPaste } = useSearchStore(); + const { triggerTool } = useToolActions(); + const { unpinToolFromNav } = useToolNavPinning(); const { logout } = useAuthStore(); const modalStore = useModalStore(); @@ -243,6 +252,7 @@ navLayout, navLayoutReady, navHiddenKeys, + defaultHiddenKeys, menuItems, activeMenuIndex, allNavDefinitions, @@ -258,7 +268,8 @@ router, dashboardStore, dashboards, - directAccessPaste + directAccessPaste, + triggerTool }); const collapsedDropdownOpenId = ref(null); @@ -321,6 +332,14 @@ }; const isDashboardItem = (item) => item?.index?.startsWith(DASHBOARD_NAV_KEY_PREFIX); + const isToolItem = (item) => item?.index?.startsWith('tool-'); + + const handleUnpinToolItem = async (item) => { + if (!isToolItem(item)) { + return; + } + await unpinToolFromNav(item.index.replace(/^tool-/, '')); + }; const handleQuickCreateDashboard = async () => { const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name')); diff --git a/src/components/nav-menu/NavMenuFolderItem.vue b/src/components/nav-menu/NavMenuFolderItem.vue index 6f1d4d04..4d1a08e3 100644 --- a/src/components/nav-menu/NavMenuFolderItem.vue +++ b/src/components/nav-menu/NavMenuFolderItem.vue @@ -110,9 +110,12 @@ - - {{ t('dashboard.new_dashboard') }} + + {{ t('nav_menu.custom_nav.unpin_from_nav') }} + {{ t('nav_menu.custom_nav.header') }} @@ -130,9 +133,6 @@ {{ t('nav_menu.mark_all_read') }} - - {{ t('dashboard.new_dashboard') }} - {{ t('nav_menu.custom_nav.header') }} @@ -199,6 +199,10 @@ isDashboardItem: { type: Function, required: true + }, + isToolItem: { + type: Function, + required: true } }); @@ -209,7 +213,7 @@ 'clear-notifications', 'edit-dashboard', 'delete-dashboard', - 'create-dashboard', + 'unpin-tool', 'open-custom-nav' ]); const { t } = useI18n(); diff --git a/src/components/nav-menu/__tests__/NavMenu.test.js b/src/components/nav-menu/__tests__/NavMenu.test.js index 2680c842..d6637f1a 100644 --- a/src/components/nav-menu/__tests__/NavMenu.test.js +++ b/src/components/nav-menu/__tests__/NavMenu.test.js @@ -115,6 +115,20 @@ vi.mock('../../../stores', () => ({ useAuthStore: () => ({ logout: (...args) => mocks.logout(...args) }), + useToolsStore: () => ({ + openDialog: vi.fn(), + closeDialog: vi.fn(), + closeAllDialogs: vi.fn() + }), + useAdvancedSettingsStore: () => ({ + showVRChatConfig: vi.fn() + }), + useLaunchStore: () => ({ + showLaunchOptions: vi.fn() + }), + useVrcxStore: () => ({ + showRegistryBackupDialog: vi.fn() + }), useAppearanceSettingsStore: () => ({ themeMode: mocks.themeMode, tableDensity: mocks.tableDensity, @@ -148,11 +162,13 @@ vi.mock('../../../services/config', () => ({ vi.mock('../../../shared/constants', () => ({ DASHBOARD_NAV_KEY_PREFIX: 'dashboard-', + defaultHiddenToolNavKeys: [], THEME_CONFIG: { system: { name: 'System' }, light: { name: 'Light' }, dark: { name: 'Dark' } }, + isToolNavKey: (key) => typeof key === 'string' && key.startsWith('tool-'), links: { github: 'https://github.com/vrcx-team/VRCX' }, @@ -171,7 +187,9 @@ vi.mock('../../../shared/constants', () => ({ tooltip: 'nav_tooltip.direct_access', icon: 'ri-door-open-line' } - ] + ], + toolDefinitionMap: new Map(), + toolDefinitions: [] })); vi.mock('../navMenuUtils', () => ({ diff --git a/src/components/nav-menu/composables/useNavLayout.js b/src/components/nav-menu/composables/useNavLayout.js index 7d16c4de..566ae263 100644 --- a/src/components/nav-menu/composables/useNavLayout.js +++ b/src/components/nav-menu/composables/useNavLayout.js @@ -1,19 +1,32 @@ -import { computed, ref, watch } from 'vue'; - -import dayjs from 'dayjs'; +import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import configRepository from '../../../services/config'; import { DASHBOARD_NAV_KEY_PREFIX, + isToolNavKey, navDefinitions } from '../../../shared/constants'; import { triggerNavEntryAction } from '../navActionUtils'; import { buildMenuItems, - collectLayoutKeys, findFirstNavEntry, findFirstNavKey } from '../navLayoutHelpers'; +import { + createBaseDefaultNavLayout, + insertDashboardEntries +} from '../navLayoutDefaults'; +import { + dispatchNavLayoutUpdated, + NAV_LAYOUT_UPDATED_EVENT +} from '../navLayoutEvents'; +import { + buildNavDefinitionsForLayout, + createNavDefinitionMap, + generateNavFolderId, + loadStoredNavConfig, + NAV_CONFIG_KEY +} from '../navConfigUtils'; import { normalizeHiddenKeys, sanitizeLayout } from '../navMenuUtils'; export function useNavLayout({ @@ -22,7 +35,8 @@ export function useNavLayout({ router, dashboardStore, dashboards, - directAccessPaste + directAccessPaste, + triggerTool }) { const navLayout = ref([]); const navLayoutReady = ref(false); @@ -33,49 +47,17 @@ export function useNavLayout({ ...dashboardStore.getDashboardNavDefinitions() ]); - const navDefinitionMap = computed(() => { - const map = new Map(); - allNavDefinitions.value.forEach((item) => { - map.set(item.key, item); - }); - return map; - }); + const navDefinitionMap = computed(() => + createNavDefinitionMap(allNavDefinitions.value) + ); - 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' } - ]; + // Tool nav items are add/remove only; they no longer participate in hidden state. + const getDefaultHiddenKeys = (layout = []) => { + void layout; + return []; + }; + + const createDefaultNavLayout = () => createBaseDefaultNavLayout(t); const menuItems = computed(() => buildMenuItems(navLayout.value, navDefinitionMap.value, t) @@ -95,43 +77,36 @@ export function useNavLayout({ return `${DASHBOARD_NAV_KEY_PREFIX}${currentRoute.params.id}`; } const currentRouteName = currentRoute?.name; - const navKey = currentRoute?.meta?.navKey || currentRouteName; - if (!navKey) { + const navKeys = Array.isArray(currentRoute?.meta?.navKeys) + ? currentRoute.meta.navKeys + : [currentRoute?.meta?.navKey || currentRouteName].filter(Boolean); + if (!navKeys.length) { return getFirstNavKeyLocal(navLayout.value) || 'feed'; } for (const entry of navLayout.value) { - if (entry.type === 'item' && entry.key === navKey) { + if (entry.type === 'item' && navKeys.includes(entry.key)) { return entry.key; } - if (entry.type === 'folder' && entry.items?.includes(navKey)) { - return navKey; + if (entry.type === 'folder') { + const matchedKey = navKeys.find((key) => + entry.items?.includes(key) + ); + if (matchedKey) { + return matchedKey; + } } } 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 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]; + return buildNavDefinitionsForLayout( + navDefinitions, + dashboardStore.getDashboardNavDefinitions(), + layout, + hiddenKeys + ); }; const sanitizeLayoutLocal = (layout, hiddenKeys = []) => { @@ -141,41 +116,36 @@ export function useNavLayout({ navDefinitionMap.value, getAppendDefinitions(layout, hiddenKeys), t, - generateFolderId + generateNavFolderId ); }; 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 base = insertDashboardEntries( + createDefaultNavLayout(), + dashboardStore.getDashboardNavDefinitions() + ); + return sanitizeLayoutLocal(base, getDefaultHiddenKeys(base)); }); const triggerNavAction = (entry) => { - triggerNavEntryAction(entry, { router, directAccessPaste }); + const action = triggerNavEntryAction(entry, { + router, + directAccessPaste + }); + if (action?.type === 'tool' && action.toolKey) { + triggerTool?.(action.toolKey); + } }; const saveNavLayout = async (layout, hiddenKeys = []) => { const normalizedHiddenKeys = normalizeHiddenKeys( - hiddenKeys, + [...hiddenKeys, ...getDefaultHiddenKeys(layout)], navDefinitionMap.value ); try { await configRepository.setString( - 'VRCX_customNavMenuLayoutList', + NAV_CONFIG_KEY, JSON.stringify({ layout, hiddenKeys: normalizedHiddenKeys @@ -183,12 +153,15 @@ export function useNavLayout({ ); } catch (error) { console.error('Failed to save custom nav', error); + return; } + + dispatchNavLayoutUpdated(); }; const applyCustomNavLayout = async (layout, hiddenKeys = []) => { const normalizedHiddenKeys = normalizeHiddenKeys( - hiddenKeys, + [...hiddenKeys, ...getDefaultHiddenKeys(layout)], navDefinitionMap.value ); const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys); @@ -221,30 +194,26 @@ export function useNavLayout({ 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 - : []; + const loaded = await loadStoredNavConfig( + configRepository, + createDefaultNavLayout(), + { + configKey: NAV_CONFIG_KEY, + filterHiddenKey: (key) => !isToolNavKey(key) } - } + ); + layoutData = loaded.layout; + hiddenKeysData = loaded.hiddenKeys; } catch (error) { console.error('Failed to load custom nav', error); } finally { + const fallbackLayout = layoutData?.length + ? layoutData + : createDefaultNavLayout(); const normalizedHiddenKeys = normalizeHiddenKeys( hiddenKeysData, navDefinitionMap.value ); - const fallbackLayout = layoutData?.length - ? layoutData - : createDefaultNavLayout(); const sanitized = sanitizeLayoutLocal( fallbackLayout, normalizedHiddenKeys @@ -339,10 +308,35 @@ export function useNavLayout({ { deep: true } ); + const handleExternalNavLayoutUpdate = async () => { + await loadNavMenuConfig(); + }; + + onMounted(() => { + if (typeof window === 'undefined') { + return; + } + window.addEventListener( + NAV_LAYOUT_UPDATED_EVENT, + handleExternalNavLayoutUpdate + ); + }); + + onUnmounted(() => { + if (typeof window === 'undefined') { + return; + } + window.removeEventListener( + NAV_LAYOUT_UPDATED_EVENT, + handleExternalNavLayoutUpdate + ); + }); + return { navLayout, navLayoutReady, navHiddenKeys, + defaultHiddenKeys: computed(() => getDefaultHiddenKeys(defaultNavLayout.value)), menuItems, activeMenuIndex, allNavDefinitions, diff --git a/src/components/nav-menu/navActionUtils.js b/src/components/nav-menu/navActionUtils.js index 090fa100..fb8adbe9 100644 --- a/src/components/nav-menu/navActionUtils.js +++ b/src/components/nav-menu/navActionUtils.js @@ -40,6 +40,10 @@ export function triggerNavEntryAction(entry, { router, directAccessPaste }) { return; } + if (entry.action && typeof entry.action === 'object') { + return entry.action; + } + if (entry.routeName) { navigateToRoute(router, entry.routeName, entry.routeParams); return; diff --git a/src/components/nav-menu/navConfigUtils.js b/src/components/nav-menu/navConfigUtils.js new file mode 100644 index 00000000..40f8766f --- /dev/null +++ b/src/components/nav-menu/navConfigUtils.js @@ -0,0 +1,80 @@ +import dayjs from 'dayjs'; + +import { isToolNavKey } from '../../shared/constants'; +import { collectLayoutKeys } from './navLayoutHelpers'; + +export const NAV_CONFIG_KEY = 'VRCX_customNavMenuLayoutList'; + +export function generateNavFolderId() { + if ( + typeof crypto !== 'undefined' && + typeof crypto.randomUUID === 'function' + ) { + return `nav-folder-${crypto.randomUUID()}`; + } + + return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`; +} + +export function createNavDefinitionMap(definitions = []) { + const map = new Map(); + definitions.forEach((definition) => { + if (definition?.key) { + map.set(definition.key, definition); + } + }); + return map; +} + +export function buildNavDefinitionsForLayout( + baseDefinitions = [], + dashboardDefinitions = [], + layout = [], + hiddenKeys = [] +) { + const keysInLayout = collectLayoutKeys(layout); + const hiddenSet = new Set(Array.isArray(hiddenKeys) ? hiddenKeys : []); + const visibleBaseDefinitions = baseDefinitions.filter( + (definition) => + !isToolNavKey(definition.key) || keysInLayout.has(definition.key) + ); + const visibleDashboardDefinitions = dashboardDefinitions.filter( + (definition) => + keysInLayout.has(definition.key) || hiddenSet.has(definition.key) + ); + + return [...visibleBaseDefinitions, ...visibleDashboardDefinitions]; +} + +export async function loadStoredNavConfig( + repository, + fallbackLayout, + { + configKey = NAV_CONFIG_KEY, + filterHiddenKey = () => true + } = {} +) { + let layout = fallbackLayout; + let hiddenKeys = []; + + const storedValue = await repository.getString(configKey); + if (!storedValue) { + return { layout, hiddenKeys }; + } + + try { + const parsed = JSON.parse(storedValue); + if (Array.isArray(parsed)) { + layout = parsed; + } else if (Array.isArray(parsed?.layout)) { + layout = parsed.layout; + hiddenKeys = Array.isArray(parsed.hiddenKeys) + ? parsed.hiddenKeys.filter(filterHiddenKey) + : []; + } + } catch { + // keep defaults + } + + return { layout, hiddenKeys }; +} diff --git a/src/components/nav-menu/navLayoutDefaults.js b/src/components/nav-menu/navLayoutDefaults.js new file mode 100644 index 00000000..aeabb5c2 --- /dev/null +++ b/src/components/nav-menu/navLayoutDefaults.js @@ -0,0 +1,67 @@ +import { DASHBOARD_NAV_KEY_PREFIX } from '../../shared/constants'; + +export function createBaseDefaultNavLayout(t) { + return [ + { 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' } + ]; +} + +export function insertDashboardEntries(layout, dashboardDefinitions) { + const nextLayout = Array.isArray(layout) ? [...layout] : []; + const dashboardEntries = (dashboardDefinitions || []).map((def) => ({ + type: 'item', + key: def.key + })); + + if (!dashboardEntries.length) { + return nextLayout; + } + + const directAccessIdx = nextLayout.findIndex( + (entry) => entry.type === 'item' && entry.key === 'direct-access' + ); + + if (directAccessIdx !== -1) { + nextLayout.splice(directAccessIdx, 0, ...dashboardEntries); + } else { + nextLayout.push(...dashboardEntries); + } + + return nextLayout; +} + +export function isDashboardNavKey(key) { + return String(key || '').startsWith(DASHBOARD_NAV_KEY_PREFIX); +} diff --git a/src/components/nav-menu/navLayoutEvents.js b/src/components/nav-menu/navLayoutEvents.js new file mode 100644 index 00000000..94e31659 --- /dev/null +++ b/src/components/nav-menu/navLayoutEvents.js @@ -0,0 +1,9 @@ +export const NAV_LAYOUT_UPDATED_EVENT = 'vrcx:nav-layout-updated'; + +export function dispatchNavLayoutUpdated() { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent(NAV_LAYOUT_UPDATED_EVENT)); +} diff --git a/src/composables/useToolActions.js b/src/composables/useToolActions.js new file mode 100644 index 00000000..e71a8e0b --- /dev/null +++ b/src/composables/useToolActions.js @@ -0,0 +1,111 @@ +import { toast } from 'vue-sonner'; +import { useRouter } from 'vue-router'; +import { useI18n } from 'vue-i18n'; + +import { + toolDefinitionMap, + toolDefinitions +} from '../shared/constants'; +import { + useAdvancedSettingsStore, + useLaunchStore, + useToolsStore, + useVrcxStore +} from '../stores'; + +/** + * @param {object} definition + * @param {object} deps + * @param {object} deps.router + * @param {Function} deps.t + * @param {object} deps.toolsStore + * @param {object} deps.advancedSettingsStore + * @param {object} deps.launchStore + * @param {object} deps.vrcxStore + */ +export async function executeToolAction( + definition, + { + router, + t, + toolsStore, + advancedSettingsStore, + launchStore, + vrcxStore + } +) { + if (!definition?.action) { + return; + } + + const { action } = definition; + + if (action.type === 'route') { + router.push({ name: action.routeName }); + return; + } + + if (action.type === 'dialog') { + toolsStore.openDialog(toDialogStoreKey(action.dialogKey)); + return; + } + + if (action.type === 'store-action') { + const targetStore = { + advancedSettings: advancedSettingsStore, + launch: launchStore, + vrcx: vrcxStore + }[action.target]; + + targetStore?.[action.method]?.(); + return; + } + + if (action.type === 'app-api') { + const result = await AppApi[action.method](); + if (result) { + toast.success(t(action.successMessageKey)); + return; + } + toast.error(t(action.errorMessageKey)); + } +} + +/** + * @param {string} dialogKey + * @returns {string} + */ +export function toDialogStoreKey(dialogKey) { + return dialogKey.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} + +export function useToolActions() { + const router = useRouter(); + const { t } = useI18n(); + + const toolsStore = useToolsStore(); + const advancedSettingsStore = useAdvancedSettingsStore(); + const launchStore = useLaunchStore(); + const vrcxStore = useVrcxStore(); + + async function triggerTool(toolOrKey) { + const definition = + typeof toolOrKey === 'string' + ? toolDefinitionMap.get(toolOrKey) + : toolOrKey; + + await executeToolAction(definition, { + router, + t, + toolsStore, + advancedSettingsStore, + launchStore, + vrcxStore + }); + } + + return { + toolDefinitions, + triggerTool + }; +} diff --git a/src/composables/useToolNavPinning.js b/src/composables/useToolNavPinning.js new file mode 100644 index 00000000..ee3890fa --- /dev/null +++ b/src/composables/useToolNavPinning.js @@ -0,0 +1,269 @@ +import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { toast } from 'vue-sonner'; + +import configRepository from '../services/config'; +import { + navDefinitions +} from '../shared/constants'; +import { useDashboardStore } from '../stores'; +import { + createBaseDefaultNavLayout, + insertDashboardEntries +} from '../components/nav-menu/navLayoutDefaults'; +import { collectLayoutKeys } from '../components/nav-menu/navLayoutHelpers'; +import { + buildNavDefinitionsForLayout, + createNavDefinitionMap, + generateNavFolderId, + loadStoredNavConfig, + NAV_CONFIG_KEY +} from '../components/nav-menu/navConfigUtils'; +import { + normalizeHiddenKeys, + sanitizeLayout +} from '../components/nav-menu/navMenuUtils'; +import { + dispatchNavLayoutUpdated, + NAV_LAYOUT_UPDATED_EVENT +} from '../components/nav-menu/navLayoutEvents'; + +function insertToolNavItem(layout, navKey, t, placement = 'top-level') { + const nextLayout = Array.isArray(layout) ? [...layout] : []; + const alreadyExists = nextLayout.some((entry) => { + if (entry.type === 'item') { + return entry.key === navKey; + } + return entry.type === 'folder' && entry.items?.includes(navKey); + }); + + if (alreadyExists) { + return nextLayout; + } + + const insertIdx = nextLayout.findIndex( + (entry) => + entry.type === 'item' && + (entry.key === 'tools' || entry.key === 'direct-access') + ); + + if (placement === 'top-level') { + if (insertIdx === -1) { + nextLayout.push({ type: 'item', key: navKey }); + } else { + nextLayout.splice(insertIdx, 0, { type: 'item', key: navKey }); + } + + return nextLayout; + } + + const folderWithTools = nextLayout.find( + (entry) => + entry.type === 'folder' && + (entry.items || []).some((key) => String(key).startsWith('tool-')) + ); + + if (folderWithTools) { + return nextLayout.map((entry) => { + if (entry !== folderWithTools) { + return entry; + } + + return { + ...entry, + items: [...(entry.items || []), navKey] + }; + }); + } + + const toolsFolder = { + type: 'folder', + id: 'default-folder-tools-shortcuts', + nameKey: 'nav_tooltip.tools', + name: t('nav_tooltip.tools'), + icon: 'ri-tools-line', + items: [navKey] + }; + + if (insertIdx === -1) { + nextLayout.push(toolsFolder); + } else { + nextLayout.splice(insertIdx, 0, toolsFolder); + } + + return nextLayout; +} + +function removeToolNavItem(layout, navKey) { + if (!Array.isArray(layout)) { + return []; + } + + return layout + .map((entry) => { + if (entry.type === 'item') { + return entry.key === navKey ? null : entry; + } + + if (entry.type === 'folder') { + const nextItems = (entry.items || []).filter( + (key) => key !== navKey + ); + if (!nextItems.length) { + return null; + } + return { + ...entry, + items: nextItems + }; + } + + return entry; + }) + .filter(Boolean); +} + +export function useToolNavPinning() { + const { t } = useI18n(); + const dashboardStore = useDashboardStore(); + const pinnedToolKeysRef = ref(new Set()); + + const buildDefinitions = () => [ + ...navDefinitions, + ...dashboardStore.getDashboardNavDefinitions() + ]; + + // Tool nav items are add/remove only; they do not use hidden state anymore. + const getDefaultHiddenKeys = () => []; + + const buildDefaultLayout = () => + insertDashboardEntries( + createBaseDefaultNavLayout(t), + dashboardStore.getDashboardNavDefinitions() + ); + + const buildSanitizeDefinitions = (layout = [], hiddenKeys = []) => { + return buildNavDefinitionsForLayout( + navDefinitions, + dashboardStore.getDashboardNavDefinitions(), + layout, + hiddenKeys + ); + }; + + const loadConfig = async () => { + return loadStoredNavConfig(configRepository, buildDefaultLayout(), { + configKey: NAV_CONFIG_KEY, + filterHiddenKey: (key) => !key?.startsWith('tool-') + }); + }; + + const refreshPinnedState = async () => { + const { layout, hiddenKeys } = await loadConfig(); + const layoutKeys = collectLayoutKeys(layout); + const nextPinned = new Set(); + + layoutKeys.forEach((key) => { + if (key.startsWith('tool-')) { + nextPinned.add(key.replace(/^tool-/, '')); + } + }); + + pinnedToolKeysRef.value = nextPinned; + }; + + const pinToolToNav = async (toolKey, options = {}) => { + const navKey = `tool-${toolKey}`; + const { layout, hiddenKeys } = await loadConfig(); + const nextLayout = insertToolNavItem( + layout, + navKey, + t, + options.placement + ); + const nextHiddenKeys = hiddenKeys.filter((key) => key !== navKey); + const definitions = buildSanitizeDefinitions(nextLayout, nextHiddenKeys); + const definitionMap = createNavDefinitionMap(buildDefinitions()); + const normalizedHiddenKeys = normalizeHiddenKeys( + nextHiddenKeys, + definitionMap + ); + const sanitizedLayout = sanitizeLayout( + nextLayout, + normalizedHiddenKeys, + definitionMap, + definitions, + t, + generateNavFolderId + ); + + await configRepository.setString( + NAV_CONFIG_KEY, + JSON.stringify({ + layout: sanitizedLayout, + hiddenKeys: normalizedHiddenKeys + }) + ); + + await refreshPinnedState(); + toast.success(t('nav_menu.custom_nav.pinned')); + dispatchNavLayoutUpdated(); + }; + + const unpinToolFromNav = async (toolKey) => { + const navKey = `tool-${toolKey}`; + const { layout, hiddenKeys } = await loadConfig(); + const nextLayout = removeToolNavItem(layout, navKey); + const nextHiddenKeys = hiddenKeys.filter((key) => key !== navKey); + const definitions = buildSanitizeDefinitions(nextLayout, nextHiddenKeys); + const definitionMap = createNavDefinitionMap(buildDefinitions()); + const normalizedHiddenKeys = normalizeHiddenKeys( + nextHiddenKeys, + definitionMap + ); + const sanitizedLayout = sanitizeLayout( + nextLayout, + normalizedHiddenKeys, + definitionMap, + definitions, + t, + generateNavFolderId + ); + + await configRepository.setString( + NAV_CONFIG_KEY, + JSON.stringify({ + layout: sanitizedLayout, + hiddenKeys: normalizedHiddenKeys + }) + ); + + await refreshPinnedState(); + toast.success(t('nav_menu.custom_nav.unpinned')); + dispatchNavLayoutUpdated(); + }; + + onMounted(() => { + if (typeof window === 'undefined') { + return; + } + window.addEventListener(NAV_LAYOUT_UPDATED_EVENT, refreshPinnedState); + }); + + onUnmounted(() => { + if (typeof window === 'undefined') { + return; + } + window.removeEventListener( + NAV_LAYOUT_UPDATED_EVENT, + refreshPinnedState + ); + }); + + return { + pinnedToolKeys: computed(() => pinnedToolKeysRef.value), + pinToolToNav, + unpinToolFromNav, + refreshPinnedState + }; +} diff --git a/src/localization/en.json b/src/localization/en.json index 0356002d..9a66edbd 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -12,6 +12,8 @@ "open": "Open", "confirm": "Confirm", "clear": "Clear", + "delete": "Delete", + "remove": "Remove", "reset": "Reset", "view_details": "View Details" }, @@ -75,6 +77,10 @@ "hide": "Hide", "show": "Show", "hidden_items": "Hidden", + "pin_to_nav": "Pin to Navigation", + "unpin_from_nav": "Unpin", + "pinned": "Added to navigation", + "unpinned": "Removed from navigation", "confirm": "Confirm", "cancel": "Cancel", "restore_default": "Restore Default", @@ -2417,6 +2423,7 @@ "file": { "not_image": "File isn't an image", "too_large": "File size too large", + "folder_opened": "Folder opened", "folder_missing": "Folder doesn't exist" }, "group": { diff --git a/src/plugins/router.js b/src/plugins/router.js index b517a522..d773f16e 100644 --- a/src/plugins/router.js +++ b/src/plugins/router.js @@ -114,13 +114,13 @@ const routes = [ path: 'tools/gallery', name: 'gallery', component: Gallery, - meta: { navKey: 'tools' } + meta: { navKeys: ['tool-gallery', 'tools'] } }, { path: 'tools/screenshot-metadata', name: 'screenshot-metadata', component: ScreenshotMetadata, - meta: { navKey: 'tools' } + meta: { navKeys: ['tool-screenshot-metadata', 'tools'] } }, { path: 'settings', name: 'settings', component: Settings } ] diff --git a/src/shared/constants/index.js b/src/shared/constants/index.js index e061f805..a716054c 100644 --- a/src/shared/constants/index.js +++ b/src/shared/constants/index.js @@ -14,3 +14,4 @@ export * from './ui'; export * from './accessType'; export * from './tags'; export * from './dashboard'; +export * from './tools'; diff --git a/src/shared/constants/tools.js b/src/shared/constants/tools.js new file mode 100644 index 00000000..e63e3420 --- /dev/null +++ b/src/shared/constants/tools.js @@ -0,0 +1,257 @@ +const toolCategories = [ + { key: 'image', labelKey: 'view.tools.pictures.header' }, + { key: 'shortcuts', labelKey: 'view.tools.shortcuts.header' }, + { key: 'system', labelKey: 'view.tools.system_tools.header' }, + { key: 'group', labelKey: 'view.tools.group.header' }, + { key: 'user', labelKey: 'view.tools.export.header' }, + { key: 'other', labelKey: 'view.tools.other.header' } +]; + +const toolDefinitions = [ + { + key: 'screenshot-metadata', + category: 'image', + iconKey: 'camera', + navIcon: 'ri-camera-line', + titleKey: 'view.tools.pictures.screenshot', + descriptionKey: 'view.tools.pictures.screenshot_description', + navEligible: true, + action: { type: 'route', routeName: 'screenshot-metadata' } + }, + { + key: 'gallery', + category: 'image', + iconKey: 'image', + navIcon: 'ri-image-line', + titleKey: 'view.tools.pictures.inventory', + descriptionKey: 'view.tools.pictures.inventory_description', + navEligible: true, + action: { type: 'route', routeName: 'gallery' } + }, + { + key: 'vrc-photos', + category: 'shortcuts', + iconKey: 'folder-open', + navIcon: 'ri-folder-image-line', + titleKey: 'view.tools.pictures.pictures.vrc_photos', + descriptionKey: 'view.tools.pictures.pictures.vrc_photos_description', + navEligible: true, + action: { + type: 'app-api', + method: 'OpenVrcPhotosFolder', + successMessageKey: 'message.file.folder_opened', + errorMessageKey: 'message.file.folder_missing' + } + }, + { + key: 'steam-screenshots', + category: 'shortcuts', + iconKey: 'folder-image', + navIcon: 'ri-folder-image-line', + titleKey: 'view.tools.pictures.pictures.steam_screenshots', + descriptionKey: 'view.tools.pictures.pictures.steam_screenshots_description', + navEligible: true, + action: { + type: 'app-api', + method: 'OpenVrcScreenshotsFolder', + successMessageKey: 'message.file.folder_opened', + errorMessageKey: 'message.file.folder_missing' + } + }, + { + key: 'vrcx-data', + category: 'shortcuts', + iconKey: 'folder-cog', + navIcon: 'ri-folder-settings-line', + titleKey: 'view.tools.shortcuts.vrcx_data', + descriptionKey: 'view.tools.shortcuts.vrcx_data_description', + navEligible: true, + action: { + type: 'app-api', + method: 'OpenVrcxAppDataFolder', + successMessageKey: 'message.file.folder_opened', + errorMessageKey: 'message.file.folder_missing' + } + }, + { + key: 'vrchat-data', + category: 'shortcuts', + iconKey: 'folder-cog', + navIcon: 'ri-folder-settings-line', + titleKey: 'view.tools.shortcuts.vrchat_data', + descriptionKey: 'view.tools.shortcuts.vrchat_data_description', + navEligible: true, + action: { + type: 'app-api', + method: 'OpenVrcAppDataFolder', + successMessageKey: 'message.file.folder_opened', + errorMessageKey: 'message.file.folder_missing' + } + }, + { + key: 'crash-dumps', + category: 'shortcuts', + iconKey: 'folder-x', + navIcon: 'ri-folder-warning-line', + titleKey: 'view.tools.shortcuts.crash_dumps', + descriptionKey: 'view.tools.shortcuts.crash_dumps_description', + navEligible: true, + action: { + type: 'app-api', + method: 'OpenCrashVrcCrashDumps', + successMessageKey: 'message.file.folder_opened', + errorMessageKey: 'message.file.folder_missing' + } + }, + { + key: 'vrchat-config', + category: 'system', + iconKey: 'sliders-horizontal', + navIcon: 'ri-settings-3-line', + titleKey: 'view.tools.system_tools.vrchat_config', + descriptionKey: 'view.tools.system_tools.vrchat_config_description', + navEligible: true, + action: { + type: 'store-action', + target: 'advancedSettings', + method: 'showVRChatConfig' + } + }, + { + key: 'launch-options', + category: 'system', + iconKey: 'terminal', + navIcon: 'ri-terminal-box-line', + titleKey: 'view.settings.advanced.advanced.launch_options', + descriptionKey: 'view.tools.system_tools.launch_options_description', + navEligible: true, + action: { + type: 'store-action', + target: 'launch', + method: 'showLaunchOptions' + } + }, + { + key: 'registry-backup', + category: 'system', + iconKey: 'archive', + navIcon: 'ri-archive-stack-line', + titleKey: 'view.settings.advanced.advanced.vrc_registry_backup', + descriptionKey: 'view.tools.system_tools.registry_backup_description', + navEligible: true, + action: { + type: 'store-action', + target: 'vrcx', + method: 'showRegistryBackupDialog' + } + }, + { + key: 'auto-change-status', + category: 'system', + iconKey: 'bot', + navIcon: 'ri-user-settings-line', + titleKey: 'view.settings.general.automation.auto_change_status', + descriptionKey: 'view.settings.general.automation.auto_state_change_tooltip', + navEligible: true, + action: { type: 'dialog', dialogKey: 'auto-change-status' } + }, + { + key: 'group-calendar', + category: 'group', + iconKey: 'calendar', + navIcon: 'ri-calendar-event-line', + titleKey: 'view.tools.group.calendar', + descriptionKey: 'view.tools.group.calendar_description', + navEligible: true, + action: { type: 'dialog', dialogKey: 'group-calendar' } + }, + { + key: 'discord-names', + category: 'user', + iconKey: 'users', + navIcon: 'ri-discord-line', + titleKey: 'view.tools.export.discord_names', + descriptionKey: 'view.tools.user.discord_names_description', + navEligible: true, + action: { type: 'dialog', dialogKey: 'export-discord-names' } + }, + { + key: 'export-notes', + category: 'user', + iconKey: 'file-text', + navIcon: 'ri-file-list-3-line', + titleKey: 'view.tools.export.export_notes', + descriptionKey: 'view.tools.export.export_notes_description', + navEligible: true, + action: { type: 'dialog', dialogKey: 'note-export' } + }, + { + key: 'export-friend-list', + category: 'user', + iconKey: 'users', + navIcon: 'ri-file-list-3-line', + titleKey: 'view.tools.export.export_friend_list', + descriptionKey: 'view.tools.user.export_friend_list_description', + navEligible: true, + action: { type: 'dialog', dialogKey: 'export-friends-list' } + }, + { + key: 'export-own-avatars', + category: 'user', + iconKey: 'download', + navIcon: 'ri-file-list-3-line', + titleKey: 'view.tools.export.export_own_avatars', + descriptionKey: 'view.tools.user.export_own_avatars_description', + navEligible: true, + action: { type: 'dialog', dialogKey: 'export-avatars-list' } + }, + { + key: 'edit-invite-message', + category: 'other', + iconKey: 'pencil', + navIcon: 'ri-quill-pen-line', + titleKey: 'view.tools.other.edit_invite_message', + descriptionKey: 'view.tools.other.edit_invite_message_description', + navEligible: true, + action: { type: 'dialog', dialogKey: 'edit-invite-messages' } + } +]; + +const toolDefinitionMap = new Map( + toolDefinitions.map((tool) => [tool.key, tool]) +); + +const toolNavDefinitions = toolDefinitions + .filter((tool) => tool.navEligible) + .map((tool) => ({ + key: `tool-${tool.key}`, + icon: tool.navIcon, + tooltip: tool.titleKey, + labelKey: tool.titleKey, + routeName: tool.action.type === 'route' ? tool.action.routeName : null, + action: + tool.action.type === 'route' + ? null + : { + type: 'tool', + toolKey: tool.key + }, + defaultHidden: true + })); + +const defaultHiddenToolNavKeys = toolNavDefinitions.map((tool) => tool.key); +const isToolNavKey = (key) => typeof key === 'string' && key.startsWith('tool-'); + +function getToolsByCategory(categoryKey) { + return toolDefinitions.filter((tool) => tool.category === categoryKey); +} + +export { + defaultHiddenToolNavKeys, + isToolNavKey, + toolCategories, + toolDefinitions, + toolDefinitionMap, + toolNavDefinitions, + getToolsByCategory +}; diff --git a/src/shared/constants/ui.js b/src/shared/constants/ui.js index b6608fac..a32ba84b 100644 --- a/src/shared/constants/ui.js +++ b/src/shared/constants/ui.js @@ -1,3 +1,5 @@ +import { toolNavDefinitions } from './tools'; + const navDefinitions = [ { key: 'feed', @@ -117,7 +119,8 @@ const navDefinitions = [ tooltip: 'prompt.direct_access_omni.header', labelKey: 'prompt.direct_access_omni.header', action: 'direct-access' - } + }, + ...toolNavDefinitions ]; export { navDefinitions }; diff --git a/src/stores/index.js b/src/stores/index.js index 4beb559b..6228f608 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -31,6 +31,7 @@ import { usePhotonStore } from './photon'; import { useSearchStore } from './search'; import { useSharedFeedStore } from './sharedFeed'; import { useUiStore } from './ui'; +import { useToolsStore } from './tools'; import { useUpdateLoopStore } from './updateLoop'; import { useUserStore } from './user'; import { useVRCXUpdaterStore } from './vrcxUpdater'; @@ -154,6 +155,7 @@ export function createGlobalStores() { notification: useNotificationStore(), feed: useFeedStore(), ui: useUiStore(), + tools: useToolsStore(), gameLog: useGameLogStore(), search: useSearchStore(), game: useGameStore(), @@ -198,6 +200,7 @@ export { useGeneralSettingsStore, useNotificationsSettingsStore, useWristOverlaySettingsStore, + useToolsStore, useUiStore, useUserStore, useVrStore, diff --git a/src/stores/tools.js b/src/stores/tools.js new file mode 100644 index 00000000..53b1b0a2 --- /dev/null +++ b/src/stores/tools.js @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia'; +import { reactive, toRefs } from 'vue'; + +const initialDialogState = () => ({ + groupCalendar: false, + noteExport: false, + exportDiscordNames: false, + exportFriendsList: false, + exportAvatarsList: false, + editInviteMessages: false, + autoChangeStatus: false +}); + +export const useToolsStore = defineStore('Tools', () => { + const dialogs = reactive(initialDialogState()); + + function setDialogVisible(dialogKey, value) { + if (!(dialogKey in dialogs)) { + console.warn( + `[toolsStore] Unknown dialog key "${dialogKey}" passed to setDialogVisible` + ); + return; + } + dialogs[dialogKey] = value; + } + + function openDialog(dialogKey) { + setDialogVisible(dialogKey, true); + } + + function closeDialog(dialogKey) { + setDialogVisible(dialogKey, false); + } + + function closeAllDialogs() { + Object.keys(dialogs).forEach((dialogKey) => { + dialogs[dialogKey] = false; + }); + } + + return { + ...toRefs(dialogs), + setDialogVisible, + openDialog, + closeDialog, + closeAllDialogs + }; +}); diff --git a/src/views/Layout/MainLayout.vue b/src/views/Layout/MainLayout.vue index 2b6931fa..9e1b8983 100644 --- a/src/views/Layout/MainLayout.vue +++ b/src/views/Layout/MainLayout.vue @@ -84,6 +84,8 @@ + + @@ -103,6 +105,7 @@ import ChooseFavoriteGroupDialog from '../../components/dialogs/ChooseFavoriteGroupDialog.vue'; import FriendImportDialog from '../Favorites/dialogs/FriendImportDialog.vue'; import FullscreenImagePreview from '../../components/FullscreenImagePreview.vue'; + import GlobalToolsDialogs from '../Tools/components/GlobalToolsDialogs.vue'; import GroupMemberModerationDialog from '../../components/dialogs/GroupDialog/GroupMemberModerationDialog.vue'; import InviteGroupDialog from '../../components/dialogs/InviteGroupDialog.vue'; import LaunchDialog from '../../components/dialogs/LaunchDialog.vue'; diff --git a/src/views/Tools/Tools.vue b/src/views/Tools/Tools.vue index 7bd7845e..32bddc18 100644 --- a/src/views/Tools/Tools.vue +++ b/src/views/Tools/Tools.vue @@ -4,232 +4,134 @@ {{ t('view.tools.header') }}
-
+
- - {{ t('view.tools.pictures.header') }} + @click="toggleCategory(category.key)"> + + + {{ t(category.labelKey) }} +
-
- - -
-
-
- - {{ t('view.tools.shortcuts.header') }} -
-
+ class="grid grid-cols-2 gap-4 ml-4" + v-show="!categoryCollapsed[category.key]"> - - - - -
-
+ v-for="tool in category.tools" + :key="tool.key" + :icon="tool.navIcon" + :title="t(tool.titleKey)" + :description="t(tool.descriptionKey)" + @click="triggerTool(tool)"> + +
- diff --git a/src/views/Tools/components/GlobalToolsDialogs.vue b/src/views/Tools/components/GlobalToolsDialogs.vue new file mode 100644 index 00000000..f6fd664d --- /dev/null +++ b/src/views/Tools/components/GlobalToolsDialogs.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/views/Tools/components/ToolItem.vue b/src/views/Tools/components/ToolItem.vue index dcdec042..475f89f9 100644 --- a/src/views/Tools/components/ToolItem.vue +++ b/src/views/Tools/components/ToolItem.vue @@ -2,19 +2,22 @@ import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item'; defineProps({ - icon: { type: [Object, Function], required: true }, + icon: { type: String, required: true }, title: { type: String, required: true }, description: { type: String, required: true } });