feat: add tool nav pinning and unpinning

This commit is contained in:
pa
2026-03-15 20:32:30 +09:00
parent d0f8fbfada
commit af389e645d
23 changed files with 1232 additions and 452 deletions

View File

@@ -1,6 +1,6 @@
<template>
<Dialog :open="visible" @update:open="(open) => (open ? null : handleClose())">
<DialogContent class="sm:min-w-140">
<DialogContent class="sm:min-w-180">
<DialogHeader>
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
</DialogHeader>
@@ -169,6 +169,7 @@
import { InputGroupButton, InputGroupField } from '../ui/input-group';
import { Separator } from '../ui/separator';
import { Tree } from '../ui/tree';
import { isToolNavKey } from '../../shared/constants';
import { navDefinitions } from '../../shared/constants/ui.js';
import { DASHBOARD_NAV_KEY_PREFIX, DEFAULT_DASHBOARD_ICON } from '../../shared/constants/dashboard';
import { useDashboardStore, useModalStore } from '../../stores';
@@ -188,6 +189,10 @@
type: Array,
default: () => []
},
defaultHiddenKeys: {
type: Array,
default: () => []
},
defaultLayout: {
type: Array,
default: () => []
@@ -305,7 +310,10 @@
const hiddenItems = computed(() =>
(props.definitions?.length ? props.definitions : navDefinitions)
.filter((def) => hiddenKeySet.value.has(def.key))
.filter(
(def) =>
hiddenKeySet.value.has(def.key) && !isToolNavKey(def.key)
)
.map((def) => ({
key: def.key,
icon: def.icon,
@@ -322,6 +330,11 @@
};
const handleHideItem = (key) => {
if (isToolNavKey(key)) {
removeFromLayout(key);
return;
}
let placement = null;
for (let i = 0; i < localLayout.value.length; i++) {
const entry = localLayout.value[i];
@@ -789,7 +802,7 @@
const handleReset = () => {
localLayout.value = cloneLayout(props.defaultLayout || []);
hiddenKeySet.value = new Set();
hiddenKeySet.value = new Set(props.defaultHiddenKeys || []);
hiddenPlacement.value = new Map();
expandedKeys.value = localLayout.value.filter((e) => e.type === 'folder').map((e) => e.id);
};

View File

@@ -30,6 +30,9 @@
const isDashboard = computed(() => {
return !isFolder.value && nodeValue.value?.key?.startsWith('dashboard-');
});
const isTool = computed(() => {
return !isFolder.value && nodeValue.value?.key?.startsWith('tool-');
});
const hasChildren = computed(() => props.item.hasChildren);
const level = computed(() => nodeValue.value?.level ?? 0);
const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key));
@@ -127,7 +130,11 @@
</template>
<template v-else>
<DropdownMenuItem @click="emit('hide', nodeValue.key)">
{{ t('nav_menu.custom_nav.hide') }}
{{
isTool
? t('common.actions.delete')
: t('nav_menu.custom_nav.hide')
}}
</DropdownMenuItem>
</template>
</DropdownMenuContent>

View File

@@ -66,9 +66,12 @@
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="handleQuickCreateDashboard">
{{ t('dashboard.new_dashboard') }}
<ContextMenuItem
v-if="isToolItem(item)"
@click="handleUnpinToolItem(item)">
{{ t('nav_menu.custom_nav.unpin_from_nav') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="isToolItem(item)" />
<ContextMenuItem @click="handleOpenCustomNavDialog">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
@@ -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" />
</template>
</SidebarMenu>
@@ -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'));

View File

@@ -110,9 +110,12 @@
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="emit('create-dashboard')">
{{ t('dashboard.new_dashboard') }}
<ContextMenuItem
v-if="isToolItem(entry)"
@click="emit('unpin-tool', entry)">
{{ t('nav_menu.custom_nav.unpin_from_nav') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="isToolItem(entry)" />
<ContextMenuItem @click="emit('open-custom-nav')">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
@@ -130,9 +133,6 @@
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="hasNotifications" />
<ContextMenuItem @click="emit('create-dashboard')">
{{ t('dashboard.new_dashboard') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('open-custom-nav')">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
@@ -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();

View File

@@ -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', () => ({

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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": {

View File

@@ -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 }
]

View File

@@ -14,3 +14,4 @@ export * from './ui';
export * from './accessType';
export * from './tags';
export * from './dashboard';
export * from './tools';

View File

@@ -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
};

View File

@@ -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 };

View File

@@ -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,

48
src/stores/tools.js Normal file
View File

@@ -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
};
});

View File

@@ -84,6 +84,8 @@
<SendBoopDialog></SendBoopDialog>
<ChangelogDialog></ChangelogDialog>
<GlobalToolsDialogs></GlobalToolsDialogs>
</template>
</template>
@@ -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';

View File

@@ -4,232 +4,134 @@
<span class="header">{{ t('view.tools.header') }}</span>
<div class="mt-5 px-5">
<div class="mb-6">
<div
v-for="category in categories"
:key="category.key"
class="mb-6">
<div
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
@click="toggleCategory('image')">
<ChevronDown
class="text-sm mr-2 transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed['image'] }" />
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.pictures.header') }}</span>
@click="toggleCategory(category.key)">
<i
class="ri-arrow-down-s-line mr-2 text-sm transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed[category.key] }" />
<span class="ml-1.5 text-base font-semibold">
{{ t(category.labelKey) }}
</span>
</div>
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['image']">
<ToolItem
:icon="Camera"
:title="t('view.tools.pictures.screenshot')"
:description="t('view.tools.pictures.screenshot_description')"
@click="showScreenshotMetadataPage" />
<ToolItem
:icon="Images"
:title="t('view.tools.pictures.inventory')"
:description="t('view.tools.pictures.inventory_description')"
@click="showGalleryPage" />
</div>
</div>
<div class="mb-6">
<div
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
@click="toggleCategory('shortcuts')">
<ChevronDown
class="text-sm mr-2 transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed['shortcuts'] }" />
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.shortcuts.header') }}</span>
</div>
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['shortcuts']">
class="grid grid-cols-2 gap-4 ml-4"
v-show="!categoryCollapsed[category.key]">
<ToolItem
:icon="Folder"
:title="t('view.tools.pictures.pictures.vrc_photos')"
:description="t('view.tools.pictures.pictures.vrc_photos_description')"
@click="openVrcPhotosFolder" />
<ToolItem
:icon="Folder"
:title="t('view.tools.pictures.pictures.steam_screenshots')"
:description="t('view.tools.pictures.pictures.steam_screenshots_description')"
@click="openVrcScreenshotsFolder" />
<ToolItem
:icon="Folder"
:title="t('view.tools.shortcuts.vrcx_data')"
:description="t('view.tools.shortcuts.vrcx_data_description')"
@click="openVrcxAppDataFolder" />
<ToolItem
:icon="Folder"
:title="t('view.tools.shortcuts.vrchat_data')"
:description="t('view.tools.shortcuts.vrchat_data_description')"
@click="openVrcAppDataFolder" />
<ToolItem
:icon="Folder"
:title="t('view.tools.shortcuts.crash_dumps')"
:description="t('view.tools.shortcuts.crash_dumps_description')"
@click="openCrashVrcCrashDumps" />
</div>
</div>
v-for="tool in category.tools"
:key="tool.key"
:icon="tool.navIcon"
:title="t(tool.titleKey)"
:description="t(tool.descriptionKey)"
@click="triggerTool(tool)">
<template #actions>
<TooltipWrapper
v-if="
tool.navEligible &&
pinnedToolKeys.has(tool.key)
"
side="top"
:content="
t('nav_menu.custom_nav.unpin_from_nav')
">
<Button
size="icon-xs"
variant="secondary"
class="opacity-0 transition-opacity group-hover:opacity-100"
:title="
t(
'nav_menu.custom_nav.unpin_from_nav'
)
"
:aria-label="
t(
'nav_menu.custom_nav.unpin_from_nav'
)
"
@click.stop="unpinToolFromNav(tool.key)">
<span class="relative inline-flex size-4">
<i
class="ri-side-bar-line inline-flex size-4 items-center justify-center text-base" />
<span
class="absolute -right-1 -top-1 grid size-2.5 place-items-center rounded-full bg-background shadow-sm">
<i
class="ri-subtract-line inline-flex size-2 items-center justify-center text-[10px]" />
</span>
</span>
</Button>
</TooltipWrapper>
<div class="mb-6">
<div
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
@click="toggleCategory('system')">
<ChevronDown
class="text-sm mr-2 transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed['system'] }" />
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.system_tools.header') }}</span>
</div>
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['system']">
<ToolItem
:icon="Settings"
:title="t('view.tools.system_tools.vrchat_config')"
:description="t('view.tools.system_tools.vrchat_config_description')"
@click="showVRChatConfig" />
<ToolItem
:icon="Settings"
:title="t('view.settings.advanced.advanced.launch_options')"
:description="t('view.tools.system_tools.launch_options_description')"
@click="showLaunchOptions" />
<ToolItem
:icon="Settings"
:title="t('view.settings.advanced.advanced.vrc_registry_backup')"
:description="t('view.tools.system_tools.registry_backup_description')"
@click="showRegistryBackupDialog" />
<ToolItem
:icon="Settings"
:title="t('view.settings.general.automation.auto_change_status')"
:description="t('view.settings.general.automation.auto_state_change_tooltip')"
@click="showAutoChangeStatusDialog" />
</div>
</div>
<div class="mb-6">
<div
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
@click="toggleCategory('group')">
<ChevronDown
class="text-sm mr-2 transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed['group'] }" />
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.group.header') }}</span>
</div>
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['group']">
<ToolItem
:icon="CalendarDays"
:title="t('view.tools.group.calendar')"
:description="t('view.tools.group.calendar_description')"
@click="showGroupCalendarDialog" />
</div>
</div>
<div class="mb-6">
<div
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
@click="toggleCategory('user')">
<ChevronDown
class="text-sm mr-2 transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed['user'] }" />
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.export.header') }}</span>
</div>
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['user']">
<ToolItem
:icon="FolderInput"
:title="t('view.tools.export.discord_names')"
:description="t('view.tools.user.discord_names_description')"
@click="showExportDiscordNamesDialog" />
<ToolItem
:icon="FolderInput"
:title="t('view.tools.export.export_notes')"
:description="t('view.tools.export.export_notes_description')"
@click="showNoteExportDialog" />
<ToolItem
:icon="FolderInput"
:title="t('view.tools.export.export_friend_list')"
:description="t('view.tools.user.export_friend_list_description')"
@click="showExportFriendsListDialog" />
<ToolItem
:icon="FolderInput"
:title="t('view.tools.export.export_own_avatars')"
:description="t('view.tools.user.export_own_avatars_description')"
@click="showExportAvatarsListDialog" />
</div>
</div>
<div class="mb-6">
<div
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
@click="toggleCategory('other')">
<ChevronDown
class="text-sm mr-2 transition-transform duration-300"
:class="{ '-rotate-90': categoryCollapsed['other'] }" />
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.other.header') }}</span>
</div>
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['other']">
<ToolItem
:icon="SquarePen"
:title="t('view.tools.other.edit_invite_message')"
:description="t('view.tools.other.edit_invite_message_description')"
@click="showEditInviteMessageDialog" />
<TooltipWrapper
v-else-if="tool.navEligible"
side="top"
:content="t('nav_menu.custom_nav.pin_to_nav')">
<Button
size="icon-xs"
variant="ghost"
class="opacity-0 transition-opacity group-hover:opacity-100"
:title="
t('nav_menu.custom_nav.pin_to_nav')
"
:aria-label="
t('nav_menu.custom_nav.pin_to_nav')
"
@click.stop="pinToolToNav(tool.key)">
<span class="relative inline-flex size-4">
<i
class="ri-side-bar-line inline-flex size-4 items-center justify-center text-base" />
<span
class="absolute -right-1 -top-1 grid size-2.5 place-items-center rounded-full bg-background shadow-sm">
<i
class="ri-add-line inline-flex size-2 items-center justify-center text-[10px]" />
</span>
</span>
</Button>
</TooltipWrapper>
</template>
</ToolItem>
</div>
</div>
</div>
</div>
<template v-if="isToolsTabVisible">
<GroupCalendarDialog
:visible="isGroupCalendarDialogVisible"
@close="isGroupCalendarDialogVisible = false" />
<NoteExportDialog
:isNoteExportDialogVisible="isNoteExportDialogVisible"
@close="isNoteExportDialogVisible = false" />
<ExportDiscordNamesDialog
v-model:discordNamesDialogVisible="isExportDiscordNamesDialogVisible"
:friends="friends" />
<ExportFriendsListDialog
v-model:isExportFriendsListDialogVisible="isExportFriendsListDialogVisible"
:friends="friends" />
<ExportAvatarsListDialog v-model:isExportAvatarsListDialogVisible="isExportAvatarsListDialogVisible" />
<EditInviteMessageDialog
v-model:isEditInviteMessagesDialogVisible="isEditInviteMessagesDialogVisible"
@close="isEditInviteMessagesDialogVisible = false" />
<RegistryBackupDialog />
<AutoChangeStatusDialog
:isAutoChangeStatusDialogVisible="isAutoChangeStatusDialogVisible"
@close="isAutoChangeStatusDialogVisible = false" />
</template>
</div>
</template>
<script setup>
import { CalendarDays, Camera, ChevronDown, Folder, FolderInput, Images, Settings, SquarePen } from 'lucide-vue-next';
import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ToolItem from './components/ToolItem.vue';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useFriendStore, useGalleryStore } from '../../stores';
import { useAdvancedSettingsStore } from '../../stores/settings/advanced';
import { useLaunchStore } from '../../stores/launch';
import { useVrcxStore } from '../../stores/vrcx';
import AutoChangeStatusDialog from './dialogs/AutoChangeStatusDialog.vue';
import { Button } from '@/components/ui/button';
import { TooltipWrapper } from '@/components/ui/tooltip';
import ToolItem from './components/ToolItem.vue';
import { useToolActions } from '../../composables/useToolActions';
import { useToolNavPinning } from '../../composables/useToolNavPinning';
import {
getToolsByCategory,
toolCategories
} from '../../shared/constants';
import configRepository from '../../services/config.js';
const GroupCalendarDialog = defineAsyncComponent(() => import('./dialogs/GroupCalendarDialog.vue'));
const NoteExportDialog = defineAsyncComponent(() => import('./dialogs/NoteExportDialog.vue'));
const EditInviteMessageDialog = defineAsyncComponent(() => import('./dialogs/EditInviteMessagesDialog.vue'));
const ExportDiscordNamesDialog = defineAsyncComponent(() => import('./dialogs/ExportDiscordNamesDialog.vue'));
const ExportFriendsListDialog = defineAsyncComponent(() => import('./dialogs/ExportFriendsListDialog.vue'));
const ExportAvatarsListDialog = defineAsyncComponent(() => import('./dialogs/ExportAvatarsListDialog.vue'));
import RegistryBackupDialog from './dialogs/RegistryBackupDialog.vue';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const { showGalleryPage } = useGalleryStore();
const { friends } = storeToRefs(useFriendStore());
const { showVRChatConfig } = useAdvancedSettingsStore();
const { showLaunchOptions } = useLaunchStore();
const { showRegistryBackupDialog } = useVrcxStore();
const { triggerTool } = useToolActions();
const {
pinToolToNav,
pinnedToolKeys,
refreshPinnedState,
unpinToolFromNav
} =
useToolNavPinning();
const toolsCategoryCollapsedConfigKey = 'VRCX_toolsCategoryCollapsed';
const categories = toolCategories.map((category) => ({
...category,
tools: getToolsByCategory(category.key)
}));
const categoryCollapsed = ref({
group: false,
image: false,
@@ -239,34 +141,20 @@
other: false
});
const isGroupCalendarDialogVisible = ref(false);
const isNoteExportDialogVisible = ref(false);
const isExportDiscordNamesDialogVisible = ref(false);
const isExportFriendsListDialogVisible = ref(false);
const isExportAvatarsListDialogVisible = ref(false);
const isEditInviteMessagesDialogVisible = ref(false);
const isAutoChangeStatusDialogVisible = ref(false);
const isToolsTabVisible = computed(() => route.name === 'tools');
const showGroupCalendarDialog = () => {
isGroupCalendarDialogVisible.value = true;
};
const showScreenshotMetadataPage = () => {
router.push({ name: 'screenshot-metadata' });
};
const showNoteExportDialog = () => {
isNoteExportDialogVisible.value = true;
};
const toggleCategory = (category) => {
categoryCollapsed.value[category] = !categoryCollapsed.value[category];
configRepository.setString(toolsCategoryCollapsedConfigKey, JSON.stringify(categoryCollapsed.value));
configRepository.setString(
toolsCategoryCollapsedConfigKey,
JSON.stringify(categoryCollapsed.value)
);
};
onMounted(async () => {
const storedValue = await configRepository.getString(toolsCategoryCollapsedConfigKey, '{}');
await refreshPinnedState();
const storedValue = await configRepository.getString(
toolsCategoryCollapsedConfigKey,
'{}'
);
try {
const parsed = JSON.parse(storedValue);
categoryCollapsed.value = {
@@ -277,98 +165,4 @@
// ignore invalid stored value and keep defaults
}
});
const showEditInviteMessageDialog = () => {
isEditInviteMessagesDialogVisible.value = true;
};
const showAutoChangeStatusDialog = () => {
isAutoChangeStatusDialogVisible.value = true;
};
/**
*
*/
function showExportDiscordNamesDialog() {
isExportDiscordNamesDialogVisible.value = true;
}
/**
*
*/
function showExportFriendsListDialog() {
isExportFriendsListDialogVisible.value = true;
}
/**
*
*/
function showExportAvatarsListDialog() {
isExportAvatarsListDialogVisible.value = true;
}
/**
*
*/
function openVrcPhotosFolder() {
AppApi.OpenVrcPhotosFolder().then((result) => {
if (result) {
toast.success('Folder opened');
} else {
toast.error(t('message.file.folder_missing'));
}
});
}
/**
*
*/
function openVrcScreenshotsFolder() {
AppApi.OpenVrcScreenshotsFolder().then((result) => {
if (result) {
toast.success('Folder opened');
} else {
toast.error(t('message.file.folder_missing'));
}
});
}
/**
*
*/
function openVrcxAppDataFolder() {
AppApi.OpenVrcxAppDataFolder().then((result) => {
if (result) {
toast.success('Folder opened');
} else {
toast.error(t('message.file.folder_missing'));
}
});
}
/**
*
*/
function openVrcAppDataFolder() {
AppApi.OpenVrcAppDataFolder().then((result) => {
if (result) {
toast.success('Folder opened');
} else {
toast.error(t('message.file.folder_missing'));
}
});
}
/**
*
*/
function openCrashVrcCrashDumps() {
AppApi.OpenCrashVrcCrashDumps().then((result) => {
if (result) {
toast.success('Folder opened');
} else {
toast.error(t('message.file.folder_missing'));
}
});
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<GroupCalendarDialog
:visible="groupCalendar"
@close="closeDialog('groupCalendar')" />
<NoteExportDialog
:isNoteExportDialogVisible="noteExport"
@close="closeDialog('noteExport')" />
<ExportDiscordNamesDialog
v-model:discordNamesDialogVisible="exportDiscordNames"
:friends="friends" />
<ExportFriendsListDialog
v-model:isExportFriendsListDialogVisible="exportFriendsList"
:friends="friends" />
<ExportAvatarsListDialog
v-model:isExportAvatarsListDialogVisible="exportAvatarsList" />
<EditInviteMessageDialog
v-model:isEditInviteMessagesDialogVisible="editInviteMessages"
@close="closeDialog('editInviteMessages')" />
<RegistryBackupDialog />
<AutoChangeStatusDialog
:isAutoChangeStatusDialogVisible="autoChangeStatus"
@close="closeDialog('autoChangeStatus')" />
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
import { storeToRefs } from 'pinia';
import { useFriendStore, useToolsStore } from '../../../stores';
import AutoChangeStatusDialog from '../dialogs/AutoChangeStatusDialog.vue';
import RegistryBackupDialog from '../dialogs/RegistryBackupDialog.vue';
const GroupCalendarDialog = defineAsyncComponent(
() => import('../dialogs/GroupCalendarDialog.vue')
);
const NoteExportDialog = defineAsyncComponent(
() => import('../dialogs/NoteExportDialog.vue')
);
const EditInviteMessageDialog = defineAsyncComponent(
() => import('../dialogs/EditInviteMessagesDialog.vue')
);
const ExportDiscordNamesDialog = defineAsyncComponent(
() => import('../dialogs/ExportDiscordNamesDialog.vue')
);
const ExportFriendsListDialog = defineAsyncComponent(
() => import('../dialogs/ExportFriendsListDialog.vue')
);
const ExportAvatarsListDialog = defineAsyncComponent(
() => import('../dialogs/ExportAvatarsListDialog.vue')
);
const { friends } = storeToRefs(useFriendStore());
const toolsStore = useToolsStore();
const {
autoChangeStatus,
editInviteMessages,
exportAvatarsList,
exportDiscordNames,
exportFriendsList,
groupCalendar,
noteExport
} = storeToRefs(toolsStore);
const { closeDialog } = toolsStore;
</script>

View File

@@ -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 }
});
</script>
<template>
<Item variant="outline" class="cursor-pointer hover:bg-accent/50">
<Item variant="outline" class="group cursor-pointer hover:bg-accent/50">
<ItemMedia variant="icon" class="bg-transparent border-0">
<component :is="icon" class="text-2xl" />
<i :class="[icon, 'inline-flex items-center justify-center text-2xl']" />
</ItemMedia>
<ItemContent>
<ItemTitle>{{ title }}</ItemTitle>
<div class="flex items-start gap-2">
<ItemTitle class="flex-1">{{ title }}</ItemTitle>
<slot name="actions" />
</div>
<ItemDescription>{{ description }}</ItemDescription>
</ItemContent>
</Item>