refactor custom nav dialog

This commit is contained in:
pa
2026-03-01 23:34:35 +09:00
parent a9d465017b
commit 865ae0ab05
7 changed files with 829 additions and 652 deletions

View File

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

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Tree } from './Tree.vue';
export { default as TreeItem } from './TreeItem.vue';

View File

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