mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 22:03:50 +02:00
feat: add tool nav pinning and unpinning
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
80
src/components/nav-menu/navConfigUtils.js
Normal file
80
src/components/nav-menu/navConfigUtils.js
Normal 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 };
|
||||
}
|
||||
67
src/components/nav-menu/navLayoutDefaults.js
Normal file
67
src/components/nav-menu/navLayoutDefaults.js
Normal 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);
|
||||
}
|
||||
9
src/components/nav-menu/navLayoutEvents.js
Normal file
9
src/components/nav-menu/navLayoutEvents.js
Normal 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));
|
||||
}
|
||||
111
src/composables/useToolActions.js
Normal file
111
src/composables/useToolActions.js
Normal 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
|
||||
};
|
||||
}
|
||||
269
src/composables/useToolNavPinning.js
Normal file
269
src/composables/useToolNavPinning.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './ui';
|
||||
export * from './accessType';
|
||||
export * from './tags';
|
||||
export * from './dashboard';
|
||||
export * from './tools';
|
||||
|
||||
257
src/shared/constants/tools.js
Normal file
257
src/shared/constants/tools.js
Normal 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
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
48
src/stores/tools.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
src/views/Tools/components/GlobalToolsDialogs.vue
Normal file
66
src/views/Tools/components/GlobalToolsDialogs.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user