mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
refactor custom nav dialog
This commit is contained in:
@@ -302,8 +302,9 @@
|
||||
<CustomNavDialog
|
||||
v-model:visible="customNavDialogVisible"
|
||||
:layout="navLayout"
|
||||
@save="handleCustomNavSave"
|
||||
@reset="handleCustomNavReset" />
|
||||
:hidden-keys="navHiddenKeys"
|
||||
:default-layout="defaultNavLayout"
|
||||
@save="handleCustomNavSave" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -346,7 +347,6 @@
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
useAuthStore,
|
||||
useModalStore,
|
||||
useSearchStore,
|
||||
useUiStore,
|
||||
useVRCXUpdaterStore
|
||||
@@ -360,7 +360,6 @@
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const modalStore = useModalStore();
|
||||
|
||||
const createDefaultNavLayout = () => [
|
||||
{ type: 'item', key: 'feed' },
|
||||
@@ -399,7 +398,23 @@
|
||||
];
|
||||
|
||||
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
||||
const DEFAULT_FOLDER_ICON = 'ri-menu-fold-line';
|
||||
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
|
||||
|
||||
const normalizeHiddenKeys = (hiddenKeys = []) => {
|
||||
if (!Array.isArray(hiddenKeys)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
hiddenKeys.forEach((key) => {
|
||||
if (!key || seen.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
});
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const VRCXUpdaterStore = useVRCXUpdaterStore();
|
||||
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
||||
@@ -434,14 +449,7 @@
|
||||
if (entry.type === 'folder') {
|
||||
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
|
||||
|
||||
if (folderDefinitions.length < 2) {
|
||||
folderDefinitions.forEach((definition) => {
|
||||
items.push({
|
||||
...definition,
|
||||
index: definition.key,
|
||||
titleIsCustom: false
|
||||
});
|
||||
});
|
||||
if (folderDefinitions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -518,10 +526,17 @@
|
||||
}
|
||||
);
|
||||
|
||||
const generateFolderId = () => `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
||||
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 sanitizeLayout = (layout) => {
|
||||
const sanitizeLayout = (layout, hiddenKeys = []) => {
|
||||
const usedKeys = new Set();
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const hiddenSet = new Set(normalizedHiddenKeys);
|
||||
const normalized = [];
|
||||
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
||||
|
||||
@@ -572,7 +587,7 @@
|
||||
usedKeys.add(key);
|
||||
});
|
||||
|
||||
if (folderItems.length >= 2) {
|
||||
if (folderItems.length >= 1) {
|
||||
const folderNameKey = entry.nameKey || null;
|
||||
const folderName = folderNameKey ? t(folderNameKey) : entry.name || '';
|
||||
normalized.push({
|
||||
@@ -583,15 +598,13 @@
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
items: folderItems
|
||||
});
|
||||
} else {
|
||||
folderItems.forEach((key) => appendItemEntry(key));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navDefinitions.forEach((item) => {
|
||||
if (!usedKeys.has(item.key)) {
|
||||
if (!usedKeys.has(item.key) && !hiddenSet.has(item.key)) {
|
||||
if (chartsKeys.includes(item.key)) {
|
||||
return;
|
||||
}
|
||||
@@ -599,7 +612,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (!chartsKeys.some((key) => usedKeys.has(key))) {
|
||||
if (!chartsKeys.some((key) => usedKeys.has(key)) && !chartsKeys.some((key) => hiddenSet.has(key))) {
|
||||
appendChartsFolder();
|
||||
}
|
||||
|
||||
@@ -659,13 +672,17 @@
|
||||
};
|
||||
|
||||
const customNavDialogVisible = ref(false);
|
||||
const navHiddenKeys = ref([]);
|
||||
const defaultNavLayout = computed(() => sanitizeLayout(createDefaultNavLayout(), []));
|
||||
|
||||
const saveNavLayout = async (layout) => {
|
||||
const saveNavLayout = async (layout, hiddenKeys = []) => {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
try {
|
||||
await configRepository.setString(
|
||||
'VRCX_customNavMenuLayoutList',
|
||||
JSON.stringify({
|
||||
layout
|
||||
layout,
|
||||
hiddenKeys: normalizedHiddenKeys
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -677,33 +694,18 @@
|
||||
customNavDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleCustomNavSave = async (layout) => {
|
||||
const sanitized = sanitizeLayout(layout);
|
||||
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const sanitized = sanitizeLayout(layout, normalizedHiddenKeys);
|
||||
navLayout.value = sanitized;
|
||||
await saveNavLayout(sanitized);
|
||||
navHiddenKeys.value = normalizedHiddenKeys;
|
||||
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||
customNavDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const handleCustomNavReset = () => {
|
||||
modalStore
|
||||
.confirm({
|
||||
description: t('nav_menu.custom_nav.restore_default_confirm'),
|
||||
title: t('confirm.title'),
|
||||
confirmText: t('nav_menu.custom_nav.restore_default'),
|
||||
cancelText: t('nav_menu.custom_nav.cancel')
|
||||
})
|
||||
.then(async ({ ok }) => {
|
||||
if (!ok) return;
|
||||
const defaults = sanitizeLayout(createDefaultNavLayout());
|
||||
navLayout.value = defaults;
|
||||
await saveNavLayout(defaults);
|
||||
customNavDialogVisible.value = false;
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const loadNavMenuConfig = async () => {
|
||||
let layoutData = null;
|
||||
let hiddenKeysData = [];
|
||||
try {
|
||||
const storedValue = await configRepository.getString('VRCX_customNavMenuLayoutList');
|
||||
if (storedValue) {
|
||||
@@ -712,16 +714,23 @@
|
||||
layoutData = parsed;
|
||||
} else if (Array.isArray(parsed?.layout)) {
|
||||
layoutData = parsed.layout;
|
||||
hiddenKeysData = Array.isArray(parsed.hiddenKeys) ? parsed.hiddenKeys : [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nav', error);
|
||||
} finally {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData);
|
||||
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
||||
const sanitized = sanitizeLayout(fallbackLayout);
|
||||
const sanitized = sanitizeLayout(fallbackLayout, normalizedHiddenKeys);
|
||||
navLayout.value = sanitized;
|
||||
if (layoutData?.length && JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout)) {
|
||||
await saveNavLayout(sanitized);
|
||||
navHiddenKeys.value = normalizedHiddenKeys;
|
||||
if (
|
||||
layoutData?.length &&
|
||||
(JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout) ||
|
||||
JSON.stringify(normalizedHiddenKeys) !== JSON.stringify(hiddenKeysData))
|
||||
) {
|
||||
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||
}
|
||||
navLayoutReady.value = true;
|
||||
navigateToFirstNavEntry();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
123
src/components/dialogs/SortableTreeNode.vue
Normal file
123
src/components/dialogs/SortableTreeNode.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChevronRight, Ellipsis, GripVertical } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TreeItem } from '@/components/ui/tree';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSortable } from '@dnd-kit/vue/sortable';
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
definitionsMap: { type: Map, required: true },
|
||||
dragState: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['editFolder', 'deleteFolder', 'hide', 'toggle']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const element = ref(null);
|
||||
|
||||
const nodeValue = computed(() => props.item.value);
|
||||
const isFolder = computed(() => nodeValue.value?.type === 'folder');
|
||||
const hasChildren = computed(() => props.item.hasChildren);
|
||||
const level = computed(() => nodeValue.value?.level ?? 0);
|
||||
const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key));
|
||||
|
||||
const { isDragSource } = useSortable({
|
||||
// Use business id (folder.id / item.key) so drag events align with layout lookup logic.
|
||||
id: nodeId,
|
||||
index: computed(() => props.index),
|
||||
element
|
||||
});
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (isFolder.value) {
|
||||
return nodeValue.value.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder');
|
||||
}
|
||||
const def = props.definitionsMap.get(nodeValue.value?.key);
|
||||
return def ? t(def.labelKey) : nodeValue.value?.key || '';
|
||||
});
|
||||
|
||||
const displayIcon = computed(() => {
|
||||
if (isFolder.value) {
|
||||
return nodeValue.value.icon || 'ri-folder-line';
|
||||
}
|
||||
const def = props.definitionsMap.get(nodeValue.value?.key);
|
||||
return def?.icon || '';
|
||||
});
|
||||
|
||||
// Visual indicator: highlight when this folder is the drop target
|
||||
const isDropHighlighted = computed(() => {
|
||||
if (!props.dragState?.active) return false;
|
||||
if (!isFolder.value) return false;
|
||||
return props.dragState.overTargetId === nodeId.value && props.dragState.overIsFolder;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TreeItem
|
||||
ref="element"
|
||||
:value="item.value"
|
||||
:level="level"
|
||||
v-bind="item.bind"
|
||||
class="group select-none cursor-grab active:cursor-grabbing"
|
||||
:class="[
|
||||
isDragSource ? 'opacity-40' : '',
|
||||
isFolder ? 'bg-muted/50 border-l-primary/60 border-l-2 font-semibold' : '',
|
||||
level > 0 ? 'text-muted-foreground' : '',
|
||||
isDropHighlighted ? 'ring-primary/50 bg-primary/10 ring-2' : ''
|
||||
]">
|
||||
<template #default="{ isExpanded }">
|
||||
<GripVertical class="size-4 shrink-0 text-muted-foreground opacity-50 group-hover:opacity-100" />
|
||||
|
||||
<button
|
||||
v-if="hasChildren"
|
||||
type="button"
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded transition-transform"
|
||||
:class="isExpanded ? 'rotate-90' : ''"
|
||||
@click.stop="emit('toggle')">
|
||||
<ChevronRight class="size-3.5" />
|
||||
</button>
|
||||
<span v-else class="size-4 shrink-0" />
|
||||
|
||||
<i v-if="displayIcon" :class="displayIcon" class="text-base" />
|
||||
|
||||
<span class="flex-1 truncate text-sm">{{ displayLabel }}</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
class="ml-auto size-6 shrink-0 opacity-0 group-hover:opacity-100"
|
||||
@click.stop>
|
||||
<Ellipsis class="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<template v-if="isFolder">
|
||||
<DropdownMenuItem @click="emit('editFolder', nodeValue.id)">
|
||||
{{ t('nav_menu.custom_nav.edit_folder') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem class="text-destructive" @click="emit('deleteFolder', nodeValue.id)">
|
||||
{{ t('nav_menu.custom_nav.delete_folder') }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DropdownMenuItem @click="emit('hide', nodeValue.key)">
|
||||
{{ t('nav_menu.custom_nav.hide') }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</TreeItem>
|
||||
</template>
|
||||
35
src/components/ui/tree/Tree.vue
Normal file
35
src/components/ui/tree/Tree.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { TreeRoot } from 'reka-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
getKey: { type: Function, required: false },
|
||||
getChildren: { type: Function, required: false },
|
||||
defaultExpanded: { type: Array, required: false },
|
||||
expanded: { type: Array, required: false },
|
||||
multiple: { type: Boolean, required: false, default: false },
|
||||
propagateSelect: { type: Boolean, required: false, default: false },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:expanded', 'update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TreeRoot
|
||||
:items="props.items"
|
||||
:get-key="props.getKey"
|
||||
:get-children="props.getChildren"
|
||||
:default-expanded="props.defaultExpanded"
|
||||
:expanded="props.expanded"
|
||||
:multiple="props.multiple"
|
||||
:propagate-select="props.propagateSelect"
|
||||
:class="cn('flex flex-col', props.class)"
|
||||
@update:expanded="(val) => emit('update:expanded', val)"
|
||||
@update:model-value="(val) => emit('update:modelValue', val)">
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps" />
|
||||
</template>
|
||||
</TreeRoot>
|
||||
</template>
|
||||
31
src/components/ui/tree/TreeItem.vue
Normal file
31
src/components/ui/tree/TreeItem.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { TreeItem as RekaTreeItem } from 'reka-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: Object, required: true },
|
||||
level: { type: Number, required: false, default: 0 },
|
||||
asChild: { type: Boolean, required: false, default: false },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RekaTreeItem
|
||||
v-slot="slotProps"
|
||||
:value="props.value"
|
||||
:level="props.level"
|
||||
:as-child="props.asChild"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus-visible:ring-ring focus-visible:ring-1',
|
||||
'data-selected:bg-accent data-selected:text-accent-foreground',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
:style="{ paddingLeft: `${props.level * 16 + 8}px` }">
|
||||
<slot v-bind="slotProps" />
|
||||
</RekaTreeItem>
|
||||
</template>
|
||||
2
src/components/ui/tree/index.js
Normal file
2
src/components/ui/tree/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Tree } from './Tree.vue';
|
||||
export { default as TreeItem } from './TreeItem.vue';
|
||||
@@ -59,21 +59,20 @@
|
||||
"custom_nav": {
|
||||
"header": "Customize Navigation",
|
||||
"dialog_title": "Customize Navigation Menu",
|
||||
"add_folder": "Add Folder",
|
||||
"new_folder": "New Folder",
|
||||
"folder_name_placeholder": "Folder name",
|
||||
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
|
||||
"edit_folder": "Edit Folder",
|
||||
"folder_available": "Available items",
|
||||
"folder_selected": "Folder items",
|
||||
"folder_selected_empty": "No items selected",
|
||||
"delete_folder": "Delete Folder",
|
||||
"remove_from_folder": "Remove from folder",
|
||||
"folder_empty": "No items in this folder",
|
||||
"invalid_folder": "Folder must have a name and contain at least two items.",
|
||||
"folder_empty": "This folder is empty",
|
||||
"folder_drop_here": "Drag items here",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"hidden_items": "Hidden",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"restore_default": "Restore Default",
|
||||
"restore_default_confirm": "Restore navigation to its default order?",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
"restore_default_confirm": "Restore navigation to its default order?"
|
||||
}
|
||||
},
|
||||
"side_panel": {
|
||||
|
||||
Reference in New Issue
Block a user