mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-26 10:13:48 +02:00
refactor custom nav dialog
This commit is contained in:
@@ -302,8 +302,9 @@
|
|||||||
<CustomNavDialog
|
<CustomNavDialog
|
||||||
v-model:visible="customNavDialogVisible"
|
v-model:visible="customNavDialogVisible"
|
||||||
:layout="navLayout"
|
:layout="navLayout"
|
||||||
@save="handleCustomNavSave"
|
:hidden-keys="navHiddenKeys"
|
||||||
@reset="handleCustomNavReset" />
|
:default-layout="defaultNavLayout"
|
||||||
|
@save="handleCustomNavSave" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -346,7 +347,6 @@
|
|||||||
import {
|
import {
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
useModalStore,
|
|
||||||
useSearchStore,
|
useSearchStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useVRCXUpdaterStore
|
useVRCXUpdaterStore
|
||||||
@@ -360,7 +360,6 @@
|
|||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalStore = useModalStore();
|
|
||||||
|
|
||||||
const createDefaultNavLayout = () => [
|
const createDefaultNavLayout = () => [
|
||||||
{ type: 'item', key: 'feed' },
|
{ type: 'item', key: 'feed' },
|
||||||
@@ -399,7 +398,23 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
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 VRCXUpdaterStore = useVRCXUpdaterStore();
|
||||||
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
||||||
@@ -434,14 +449,7 @@
|
|||||||
if (entry.type === 'folder') {
|
if (entry.type === 'folder') {
|
||||||
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
|
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
|
||||||
|
|
||||||
if (folderDefinitions.length < 2) {
|
if (folderDefinitions.length === 0) {
|
||||||
folderDefinitions.forEach((definition) => {
|
|
||||||
items.push({
|
|
||||||
...definition,
|
|
||||||
index: definition.key,
|
|
||||||
titleIsCustom: false
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
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 usedKeys = new Set();
|
||||||
|
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||||
|
const hiddenSet = new Set(normalizedHiddenKeys);
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
||||||
|
|
||||||
@@ -572,7 +587,7 @@
|
|||||||
usedKeys.add(key);
|
usedKeys.add(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (folderItems.length >= 2) {
|
if (folderItems.length >= 1) {
|
||||||
const folderNameKey = entry.nameKey || null;
|
const folderNameKey = entry.nameKey || null;
|
||||||
const folderName = folderNameKey ? t(folderNameKey) : entry.name || '';
|
const folderName = folderNameKey ? t(folderNameKey) : entry.name || '';
|
||||||
normalized.push({
|
normalized.push({
|
||||||
@@ -583,15 +598,13 @@
|
|||||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||||
items: folderItems
|
items: folderItems
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
folderItems.forEach((key) => appendItemEntry(key));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
navDefinitions.forEach((item) => {
|
navDefinitions.forEach((item) => {
|
||||||
if (!usedKeys.has(item.key)) {
|
if (!usedKeys.has(item.key) && !hiddenSet.has(item.key)) {
|
||||||
if (chartsKeys.includes(item.key)) {
|
if (chartsKeys.includes(item.key)) {
|
||||||
return;
|
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();
|
appendChartsFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,13 +672,17 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const customNavDialogVisible = ref(false);
|
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 {
|
try {
|
||||||
await configRepository.setString(
|
await configRepository.setString(
|
||||||
'VRCX_customNavMenuLayoutList',
|
'VRCX_customNavMenuLayoutList',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
layout
|
layout,
|
||||||
|
hiddenKeys: normalizedHiddenKeys
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -677,33 +694,18 @@
|
|||||||
customNavDialogVisible.value = true;
|
customNavDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomNavSave = async (layout) => {
|
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
|
||||||
const sanitized = sanitizeLayout(layout);
|
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||||
|
const sanitized = sanitizeLayout(layout, normalizedHiddenKeys);
|
||||||
navLayout.value = sanitized;
|
navLayout.value = sanitized;
|
||||||
await saveNavLayout(sanitized);
|
navHiddenKeys.value = normalizedHiddenKeys;
|
||||||
|
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||||
customNavDialogVisible.value = false;
|
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 () => {
|
const loadNavMenuConfig = async () => {
|
||||||
let layoutData = null;
|
let layoutData = null;
|
||||||
|
let hiddenKeysData = [];
|
||||||
try {
|
try {
|
||||||
const storedValue = await configRepository.getString('VRCX_customNavMenuLayoutList');
|
const storedValue = await configRepository.getString('VRCX_customNavMenuLayoutList');
|
||||||
if (storedValue) {
|
if (storedValue) {
|
||||||
@@ -712,16 +714,23 @@
|
|||||||
layoutData = parsed;
|
layoutData = parsed;
|
||||||
} else if (Array.isArray(parsed?.layout)) {
|
} else if (Array.isArray(parsed?.layout)) {
|
||||||
layoutData = parsed.layout;
|
layoutData = parsed.layout;
|
||||||
|
hiddenKeysData = Array.isArray(parsed.hiddenKeys) ? parsed.hiddenKeys : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load custom nav', error);
|
console.error('Failed to load custom nav', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData);
|
||||||
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
||||||
const sanitized = sanitizeLayout(fallbackLayout);
|
const sanitized = sanitizeLayout(fallbackLayout, normalizedHiddenKeys);
|
||||||
navLayout.value = sanitized;
|
navLayout.value = sanitized;
|
||||||
if (layoutData?.length && JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout)) {
|
navHiddenKeys.value = normalizedHiddenKeys;
|
||||||
await saveNavLayout(sanitized);
|
if (
|
||||||
|
layoutData?.length &&
|
||||||
|
(JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout) ||
|
||||||
|
JSON.stringify(normalizedHiddenKeys) !== JSON.stringify(hiddenKeysData))
|
||||||
|
) {
|
||||||
|
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||||
}
|
}
|
||||||
navLayoutReady.value = true;
|
navLayoutReady.value = true;
|
||||||
navigateToFirstNavEntry();
|
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": {
|
"custom_nav": {
|
||||||
"header": "Customize Navigation",
|
"header": "Customize Navigation",
|
||||||
"dialog_title": "Customize Navigation Menu",
|
"dialog_title": "Customize Navigation Menu",
|
||||||
"add_folder": "Add Folder",
|
"new_folder": "New Folder",
|
||||||
"folder_name_placeholder": "Folder name",
|
"folder_name_placeholder": "Folder name",
|
||||||
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
|
"folder_icon_placeholder": "Icon class (e.g. ri-menu-fold-line)",
|
||||||
"edit_folder": "Edit Folder",
|
"edit_folder": "Edit Folder",
|
||||||
"folder_available": "Available items",
|
|
||||||
"folder_selected": "Folder items",
|
|
||||||
"folder_selected_empty": "No items selected",
|
|
||||||
"delete_folder": "Delete Folder",
|
"delete_folder": "Delete Folder",
|
||||||
"remove_from_folder": "Remove from folder",
|
"folder_empty": "This folder is empty",
|
||||||
"folder_empty": "No items in this folder",
|
"folder_drop_here": "Drag items here",
|
||||||
"invalid_folder": "Folder must have a name and contain at least two items.",
|
"hide": "Hide",
|
||||||
|
"show": "Show",
|
||||||
|
"hidden_items": "Hidden",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
"restore_default": "Restore Default",
|
"restore_default": "Restore Default",
|
||||||
"restore_default_confirm": "Restore navigation to its default order?",
|
"restore_default_confirm": "Restore navigation to its default order?"
|
||||||
"save": "Save",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"side_panel": {
|
"side_panel": {
|
||||||
|
|||||||
Reference in New Issue
Block a user