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 @@
- {{ t('nav_menu.custom_nav.hide') }}
+ {{
+ isTool
+ ? t('common.actions.delete')
+ : t('nav_menu.custom_nav.hide')
+ }}
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.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)">
+
+
+
+
-
-
-
- {{ t('view.tools.system_tools.header') }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('view.tools.group.header') }}
-
-
-
-
-
-
-
-
-
- {{ t('view.tools.export.header') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('view.tools.other.header') }}
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
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 }
});
- -
+
-
-
+
- {{ title }}
+
+ {{ title }}
+
+
{{ description }}