mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-22 08:13:52 +02:00
821 lines
32 KiB
Vue
821 lines
32 KiB
Vue
<template>
|
|
<Dialog :open="visible" @update:open="(open) => (open ? null : handleClose())">
|
|
<DialogContent class="sm:min-w-180">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="min-h-[40vh] max-h-[60vh] overflow-y-auto">
|
|
<DragDropProvider @dragStart="onDragStart" @dragOver="onDragOver" @dragEnd="onDragEnd">
|
|
<Tree
|
|
:items="treeItems"
|
|
:get-key="(item) => item.id"
|
|
:get-children="(item) => item.children"
|
|
:expanded="expandedKeys"
|
|
class="gap-0.5 pr-3"
|
|
@update:expanded="(val) => (expandedKeys = val)">
|
|
<template #default="{ flattenItems }">
|
|
<template v-for="(item, idx) in flattenItems" :key="item._id">
|
|
<template v-if="item.value?._placeholder">
|
|
<div
|
|
class="rounded-md border border-dashed border-muted-foreground/25 p-1.5 text-sm text-muted-foreground/50 mt-1">
|
|
{{ t('nav_menu.custom_nav.folder_drop_here') }}
|
|
</div>
|
|
</template>
|
|
<SortableTreeNode
|
|
v-else
|
|
:item="item"
|
|
:index="getSortableIndex(idx, flattenItems)"
|
|
:definitions-map="definitionsMap"
|
|
:drag-state="dragState"
|
|
@edit-folder="openFolderEditor"
|
|
@delete-folder="handleDeleteFolder"
|
|
@edit-dashboard="openDashboardEditor"
|
|
@delete-dashboard="handleDeleteDashboard"
|
|
@hide="handleHideItem"
|
|
@toggle="handleTreeToggle(item)" />
|
|
</template>
|
|
</template>
|
|
</Tree>
|
|
</DragDropProvider>
|
|
|
|
<template v-if="hiddenItems.length">
|
|
<div class="my-3 flex items-center gap-2 pr-3">
|
|
<Separator class="flex-1" />
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ t('nav_menu.custom_nav.hidden_items') }}
|
|
</span>
|
|
<Separator class="flex-1" />
|
|
</div>
|
|
<div class="flex flex-col gap-0.5 pr-3">
|
|
<div
|
|
v-for="item in hiddenItems"
|
|
:key="item.key"
|
|
class="group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground"
|
|
@click="handleShowItem(item.key)">
|
|
<span class="size-4 shrink-0" />
|
|
<span class="size-4 shrink-0" />
|
|
<i v-if="item.icon" :class="item.icon" class="text-base" />
|
|
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
class="ml-auto size-6 shrink-0 opacity-0 group-hover:opacity-100"
|
|
@click.stop="handleShowItem(item.key)">
|
|
<Minus class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<div class="flex w-full items-center justify-between">
|
|
<div class="flex gap-2">
|
|
<Button variant="outline" @click="handleAddFolder">
|
|
{{ t('nav_menu.custom_nav.new_folder') }}
|
|
</Button>
|
|
<Button variant="outline" @click="handleAddDashboard">
|
|
{{ t('dashboard.new_dashboard') }}
|
|
</Button>
|
|
<Button variant="ghost" class="text-destructive" @click="handleReset">
|
|
{{ t('nav_menu.custom_nav.restore_default') }}
|
|
</Button>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<Button variant="secondary" @click="handleClose">
|
|
{{ t('nav_menu.custom_nav.cancel') }}
|
|
</Button>
|
|
<Button @click="handleSave">
|
|
{{ t('common.actions.confirm') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:open="folderEditor.visible">
|
|
<DialogContent class="sm:max-w-100">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{{
|
|
folderEditor.editorType === 'dashboard'
|
|
? t('nav_menu.custom_nav.edit_dashboard')
|
|
: t('nav_menu.custom_nav.edit_folder')
|
|
}}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div class="flex flex-col gap-3">
|
|
<InputGroupField
|
|
v-model="folderEditor.data.name"
|
|
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
|
|
<InputGroupField
|
|
v-model="folderEditor.data.icon"
|
|
:placeholder="t('nav_menu.custom_nav.folder_icon_placeholder')">
|
|
<template #trailing>
|
|
<HoverCard>
|
|
<HoverCardTrigger as-child>
|
|
<InputGroupButton
|
|
size="icon-xs"
|
|
:aria-label="t('nav_menu.custom_nav.folder_icon_placeholder')">
|
|
<LinkIcon class="size-3.5" />
|
|
</InputGroupButton>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent side="bottom" align="end" class="w-80">
|
|
<div class="text-sm leading-snug">
|
|
<div>
|
|
Find the icon you want on this site and paste its class name here, e.g.
|
|
<span class="font-mono">ri-arrow-left-up-line</span>
|
|
</div>
|
|
<div class="mt-2">
|
|
<a
|
|
class="cursor-pointer text-blue-600"
|
|
@click.prevent="openExternalLink('https://remixicon.com/')">
|
|
https://remixicon.com/
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</template>
|
|
</InputGroupField>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="secondary" @click="folderEditor.visible = false">
|
|
{{ t('nav_menu.custom_nav.cancel') }}
|
|
</Button>
|
|
<Button :disabled="!folderEditor.data.name?.trim()" @click="handleFolderEditorSave">
|
|
{{ t('common.actions.confirm') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { computed, reactive, ref, watch } from 'vue';
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
|
import { Link as LinkIcon, Minus } from 'lucide-vue-next';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DragDropProvider } from '@dnd-kit/vue';
|
|
import { isSortable } from '@dnd-kit/vue/sortable';
|
|
import { openExternalLink } from '@/shared/utils/common';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
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, useNotificationsSettingsStore } from '../../stores';
|
|
|
|
import SortableTreeNode from './SortableTreeNode.vue';
|
|
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
layout: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
hiddenKeys: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
defaultHiddenKeys: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
defaultLayout: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
definitions: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:visible', 'save', 'dashboard-created']);
|
|
const { t } = useI18n();
|
|
const dashboardStore = useDashboardStore();
|
|
const modalStore = useModalStore();
|
|
const { notificationLayout } = storeToRefs(useNotificationsSettingsStore());
|
|
|
|
const cloneLayout = (source) => {
|
|
if (!Array.isArray(source)) return [];
|
|
return source.map((entry) => {
|
|
if (entry?.type === 'folder') {
|
|
return {
|
|
type: 'folder',
|
|
id: entry.id,
|
|
name: entry.name,
|
|
nameKey: entry.nameKey || null,
|
|
icon: entry.icon,
|
|
items: Array.isArray(entry.items) ? [...entry.items] : []
|
|
};
|
|
}
|
|
return { type: 'item', key: entry.key };
|
|
});
|
|
};
|
|
|
|
const localLayout = ref(cloneLayout(props.layout));
|
|
const hiddenKeySet = ref(new Set());
|
|
const hiddenPlacement = ref(new Map());
|
|
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
|
|
|
|
const folderEditor = reactive({
|
|
visible: false,
|
|
isEditing: false,
|
|
editingId: null,
|
|
editorType: 'folder',
|
|
data: { id: '', name: '', icon: '' }
|
|
});
|
|
|
|
const dragState = reactive({
|
|
active: false,
|
|
sourceId: null,
|
|
sourceIsFolder: false,
|
|
overTargetId: null,
|
|
overIsFolder: false,
|
|
lastOverNode: null
|
|
});
|
|
|
|
const createFolderId = () => {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return `custom-folder-${crypto.randomUUID()}`;
|
|
}
|
|
return `custom-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 7)}`;
|
|
};
|
|
|
|
watch(
|
|
() => props.visible,
|
|
(visible) => {
|
|
if (visible) {
|
|
localLayout.value = cloneLayout(props.layout);
|
|
hiddenKeySet.value = new Set(props.hiddenKeys || []);
|
|
hiddenPlacement.value = new Map();
|
|
expandedKeys.value = localLayout.value.filter((e) => e.type === 'folder').map((e) => e.id);
|
|
}
|
|
}
|
|
);
|
|
|
|
const definitionsMap = computed(() => {
|
|
const map = new Map();
|
|
const source = props.definitions?.length ? props.definitions : navDefinitions;
|
|
source.forEach((def) => {
|
|
if (def?.key) {
|
|
if (def.key === 'notification' && notificationLayout.value === 'notification-center') {
|
|
return;
|
|
}
|
|
map.set(def.key, def);
|
|
}
|
|
});
|
|
return map;
|
|
});
|
|
|
|
const treeItems = computed(() => {
|
|
return localLayout.value
|
|
.map((entry) => {
|
|
if (entry.type === 'folder') {
|
|
const children = (entry.items || [])
|
|
.map((key) => {
|
|
const def = definitionsMap.value.get(key);
|
|
if (!def) return null;
|
|
return { id: key, type: 'item', key, level: 1, parentId: entry.id };
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const folderChildren = children.length
|
|
? children
|
|
: [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }];
|
|
|
|
return {
|
|
id: entry.id,
|
|
type: 'folder',
|
|
name: entry.name,
|
|
icon: entry.icon,
|
|
level: 0,
|
|
children: folderChildren
|
|
};
|
|
}
|
|
if (!definitionsMap.value.has(entry.key)) return null;
|
|
return { id: entry.key, type: 'item', key: entry.key, level: 0 };
|
|
})
|
|
.filter(Boolean);
|
|
});
|
|
|
|
const expandedKeys = ref([]);
|
|
|
|
const hiddenItems = computed(() =>
|
|
(props.definitions?.length ? props.definitions : navDefinitions)
|
|
.filter(
|
|
(def) =>
|
|
hiddenKeySet.value.has(def.key) && !isToolNavKey(def.key)
|
|
)
|
|
.map((def) => ({
|
|
key: def.key,
|
|
icon: def.icon,
|
|
label: def.isDashboard ? def.labelKey : t(def.labelKey)
|
|
}))
|
|
);
|
|
|
|
const getSortableIndex = (originalIdx, flattenItems) => {
|
|
let sortableIdx = 0;
|
|
for (let i = 0; i < originalIdx; i++) {
|
|
if (!flattenItems[i]?.value?._placeholder) sortableIdx += 1;
|
|
}
|
|
return sortableIdx;
|
|
};
|
|
|
|
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];
|
|
if (entry.type === 'item' && entry.key === key) {
|
|
placement = { parentId: null, index: i };
|
|
localLayout.value.splice(i, 1);
|
|
break;
|
|
}
|
|
if (entry.type === 'folder') {
|
|
const idx = entry.items?.indexOf(key);
|
|
if (idx !== undefined && idx >= 0) {
|
|
placement = { parentId: String(entry.id), index: idx };
|
|
entry.items.splice(idx, 1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (placement) {
|
|
hiddenPlacement.value.set(key, placement);
|
|
}
|
|
hiddenKeySet.value.add(key);
|
|
localLayout.value = [...localLayout.value];
|
|
};
|
|
|
|
const handleShowItem = (key) => {
|
|
hiddenKeySet.value.delete(key);
|
|
hiddenKeySet.value = new Set(hiddenKeySet.value);
|
|
const placement = hiddenPlacement.value.get(key) || null;
|
|
|
|
let restored = false;
|
|
if (placement?.parentId) {
|
|
const folder = localLayout.value.find(
|
|
(entry) => entry.type === 'folder' && String(entry.id) === placement.parentId
|
|
);
|
|
if (folder) {
|
|
const insertAt = Math.max(0, Math.min(placement.index, folder.items.length));
|
|
folder.items.splice(insertAt, 0, key);
|
|
restored = true;
|
|
}
|
|
}
|
|
|
|
if (!restored && placement && placement.parentId === null) {
|
|
const insertAt = Math.max(0, Math.min(placement.index, localLayout.value.length));
|
|
localLayout.value.splice(insertAt, 0, { type: 'item', key });
|
|
restored = true;
|
|
}
|
|
|
|
if (!restored) {
|
|
localLayout.value.push({ type: 'item', key });
|
|
}
|
|
|
|
hiddenPlacement.value.delete(key);
|
|
localLayout.value = [...localLayout.value];
|
|
};
|
|
|
|
const handleDeleteFolder = (folderId) => {
|
|
const idx = localLayout.value.findIndex((e) => e.type === 'folder' && e.id === folderId);
|
|
if (idx < 0) return;
|
|
const folder = localLayout.value[idx];
|
|
const childItems = (folder.items || []).map((key) => ({ type: 'item', key }));
|
|
localLayout.value.splice(idx, 1, ...childItems);
|
|
localLayout.value = [...localLayout.value];
|
|
};
|
|
|
|
const handleTreeToggle = (item) => {
|
|
const id = item.value?.id;
|
|
if (!id) return;
|
|
if (expandedKeys.value.includes(id)) {
|
|
expandedKeys.value = expandedKeys.value.filter((k) => k !== id);
|
|
} else {
|
|
expandedKeys.value = [...expandedKeys.value, id];
|
|
}
|
|
};
|
|
|
|
const buildVisibleNodes = () => {
|
|
const list = [];
|
|
for (const entry of localLayout.value) {
|
|
if (entry.type === 'folder') {
|
|
const folderId = String(entry.id);
|
|
list.push({ type: 'folder', id: folderId });
|
|
if (!expandedKeys.value.includes(entry.id)) {
|
|
continue;
|
|
}
|
|
for (const key of entry.items || []) {
|
|
list.push({ type: 'item', id: String(key), key, parentId: folderId });
|
|
}
|
|
} else {
|
|
list.push({ type: 'item', id: String(entry.key), key: entry.key });
|
|
}
|
|
}
|
|
return list;
|
|
};
|
|
|
|
const resolveNodeFromDnDEntity = (entity, nodes, options = {}) => {
|
|
const { allowIndexFallback = true } = options;
|
|
if (!entity) return null;
|
|
|
|
if (entity.id !== undefined && entity.id !== null) {
|
|
const rawId = String(entity.id);
|
|
const normalizedId = rawId.endsWith('__placeholder') ? rawId.slice(0, -'__placeholder'.length) : rawId;
|
|
const byId = findVisibleNodeById(normalizedId, nodes);
|
|
if (byId) return byId;
|
|
}
|
|
|
|
if (allowIndexFallback && typeof entity.index === 'number' && entity.index >= 0 && entity.index < nodes.length) {
|
|
return nodes[entity.index] || null;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const resetDragState = () => {
|
|
dragState.active = false;
|
|
dragState.sourceId = null;
|
|
dragState.sourceIsFolder = false;
|
|
dragState.overTargetId = null;
|
|
dragState.overIsFolder = false;
|
|
dragState.lastOverNode = null;
|
|
};
|
|
|
|
const findVisibleNodeById = (id, nodes = null) => {
|
|
const normalizedId = String(id);
|
|
const source = nodes || buildVisibleNodes();
|
|
return source.find((node) => node.id === normalizedId) || null;
|
|
};
|
|
|
|
const getNodeIndex = (nodes, needle) => {
|
|
if (!needle) return -1;
|
|
return nodes.findIndex((node) => {
|
|
if (node.type !== needle.type) return false;
|
|
if (node.id !== needle.id) return false;
|
|
return (node.parentId || null) === (needle.parentId || null);
|
|
});
|
|
};
|
|
|
|
const onDragStart = (event) => {
|
|
const { source } = event.operation;
|
|
if (!source) return;
|
|
|
|
const nodes = buildVisibleNodes();
|
|
const sourceNode = resolveNodeFromDnDEntity(source, nodes);
|
|
if (!sourceNode) return;
|
|
|
|
dragState.active = true;
|
|
dragState.sourceId = sourceNode.id;
|
|
dragState.sourceIsFolder = sourceNode.type === 'folder';
|
|
dragState.lastOverNode = null;
|
|
};
|
|
|
|
const onDragOver = (event) => {
|
|
const { target } = event.operation;
|
|
if (!target) {
|
|
dragState.overTargetId = null;
|
|
dragState.overIsFolder = false;
|
|
return;
|
|
}
|
|
|
|
const nodes = buildVisibleNodes();
|
|
const rawTargetNode = resolveNodeFromDnDEntity(target, nodes, { allowIndexFallback: false });
|
|
let targetNode = rawTargetNode;
|
|
|
|
// hovering over a folder child maps to its parent folder as top-level target
|
|
if (dragState.sourceIsFolder && rawTargetNode?.parentId) {
|
|
const parentFolderNode = findVisibleNodeById(rawTargetNode.parentId, nodes);
|
|
if (parentFolderNode?.type === 'folder') {
|
|
targetNode = parentFolderNode;
|
|
}
|
|
}
|
|
|
|
dragState.overTargetId = targetNode?.id || null;
|
|
dragState.overIsFolder = targetNode?.type === 'folder' && !dragState.sourceIsFolder;
|
|
if (!targetNode) return;
|
|
|
|
const isSelfTarget = dragState.sourceId && String(targetNode.id) === String(dragState.sourceId);
|
|
if (isSelfTarget) return;
|
|
|
|
if (dragState.sourceIsFolder && targetNode.parentId) return;
|
|
|
|
dragState.lastOverNode = {
|
|
type: targetNode.type,
|
|
id: String(targetNode.id),
|
|
parentId: targetNode.parentId ? String(targetNode.parentId) : null
|
|
};
|
|
};
|
|
|
|
const removeItemFromEntries = (entries, key) => {
|
|
const normalizedKey = String(key);
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
if (entry.type === 'item' && String(entry.key) === normalizedKey) {
|
|
entries.splice(i, 1);
|
|
return true;
|
|
}
|
|
if (entry.type === 'folder') {
|
|
const idx = entry.items.findIndex((k) => String(k) === normalizedKey);
|
|
if (idx >= 0) {
|
|
entry.items.splice(idx, 1);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const moveItemByTarget = (sourceItemId, targetNode, movingDown) => {
|
|
const entries = cloneLayout(localLayout.value);
|
|
const removed = removeItemFromEntries(entries, sourceItemId);
|
|
if (!removed) return;
|
|
|
|
if (!targetNode) {
|
|
entries.push({ type: 'item', key: sourceItemId });
|
|
localLayout.value = entries;
|
|
return;
|
|
}
|
|
|
|
if (targetNode.type === 'folder') {
|
|
const folder = entries.find((e) => e.type === 'folder' && String(e.id) === targetNode.id);
|
|
if (!folder) return;
|
|
folder.items.push(sourceItemId);
|
|
localLayout.value = entries;
|
|
return;
|
|
}
|
|
|
|
if (targetNode.parentId) {
|
|
const folder = entries.find((e) => e.type === 'folder' && String(e.id) === targetNode.parentId);
|
|
if (!folder) return;
|
|
const targetIdx = folder.items.findIndex((k) => String(k) === targetNode.id);
|
|
if (targetIdx < 0) return;
|
|
const insertAt = targetIdx + (movingDown ? 1 : 0);
|
|
folder.items.splice(insertAt, 0, sourceItemId);
|
|
localLayout.value = entries;
|
|
return;
|
|
}
|
|
|
|
const targetTopIdx = entries.findIndex((e) => e.type === 'item' && String(e.key) === targetNode.id);
|
|
if (targetTopIdx < 0) return;
|
|
const insertAt = targetTopIdx + (movingDown ? 1 : 0);
|
|
entries.splice(insertAt, 0, { type: 'item', key: sourceItemId });
|
|
localLayout.value = entries;
|
|
};
|
|
|
|
const moveFolderByTarget = (sourceFolderId, targetNode, movingDown, allowAppendWhenNoTarget = false) => {
|
|
const entries = cloneLayout(localLayout.value);
|
|
const sourceIdx = entries.findIndex((e) => e.type === 'folder' && String(e.id) === sourceFolderId);
|
|
if (sourceIdx < 0) return;
|
|
const [folder] = entries.splice(sourceIdx, 1);
|
|
|
|
if (targetNode?.parentId) return;
|
|
|
|
if (!targetNode) {
|
|
if (!allowAppendWhenNoTarget) return;
|
|
entries.push(folder);
|
|
localLayout.value = entries;
|
|
return;
|
|
}
|
|
|
|
const targetTopIdx = entries.findIndex((e) => {
|
|
if (targetNode.type === 'folder') {
|
|
return e.type === 'folder' && String(e.id) === targetNode.id;
|
|
}
|
|
return e.type === 'item' && String(e.key) === targetNode.id;
|
|
});
|
|
if (targetTopIdx < 0) return;
|
|
|
|
const insertAt = targetTopIdx + (movingDown ? 1 : 0);
|
|
entries.splice(insertAt, 0, folder);
|
|
localLayout.value = entries;
|
|
};
|
|
|
|
const onDragEnd = (event) => {
|
|
const sourceIdSnapshot = dragState.sourceId ? String(dragState.sourceId) : null;
|
|
const sourceIsFolderSnapshot = !!dragState.sourceIsFolder;
|
|
const wasOverFolder = dragState.overIsFolder;
|
|
const overTargetId = dragState.overTargetId ? String(dragState.overTargetId) : null;
|
|
const lastOverNodeSnapshot = dragState.lastOverNode
|
|
? {
|
|
type: dragState.lastOverNode.type,
|
|
id: String(dragState.lastOverNode.id),
|
|
parentId: dragState.lastOverNode.parentId ? String(dragState.lastOverNode.parentId) : null
|
|
}
|
|
: null;
|
|
|
|
resetDragState();
|
|
|
|
if (event.canceled) return;
|
|
|
|
const { source, target } = event.operation;
|
|
if (!isSortable(source)) return;
|
|
|
|
const visibleNodes = buildVisibleNodes();
|
|
const sourceNode =
|
|
(sourceIdSnapshot
|
|
? visibleNodes.find(
|
|
(node) => node.id === sourceIdSnapshot && node.type === (sourceIsFolderSnapshot ? 'folder' : 'item')
|
|
)
|
|
: null) || resolveNodeFromDnDEntity(source, visibleNodes);
|
|
if (!sourceNode) return;
|
|
|
|
const isFolderDrag = sourceNode.type === 'folder';
|
|
const resolvedLastOverNode = lastOverNodeSnapshot
|
|
? findVisibleNodeById(lastOverNodeSnapshot.id, visibleNodes) || lastOverNodeSnapshot
|
|
: null;
|
|
const hoveredTarget = resolveNodeFromDnDEntity(target, visibleNodes, { allowIndexFallback: false });
|
|
let targetNode = resolvedLastOverNode || hoveredTarget || null;
|
|
|
|
if (isFolderDrag && targetNode?.parentId) {
|
|
targetNode = findVisibleNodeById(targetNode.parentId, visibleNodes) || targetNode;
|
|
}
|
|
|
|
if (isFolderDrag && overTargetId && !resolvedLastOverNode) {
|
|
return;
|
|
}
|
|
|
|
if (!isFolderDrag && overTargetId && wasOverFolder) {
|
|
targetNode = findVisibleNodeById(overTargetId, visibleNodes) || targetNode;
|
|
}
|
|
|
|
if (
|
|
targetNode &&
|
|
targetNode.type === sourceNode.type &&
|
|
targetNode.id === sourceNode.id &&
|
|
(targetNode.parentId || null) === (sourceNode.parentId || null)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const sourceNodeIndex = getNodeIndex(visibleNodes, sourceNode);
|
|
const targetNodeIndex = getNodeIndex(visibleNodes, targetNode);
|
|
const movingDown = sourceNodeIndex >= 0 && targetNodeIndex >= 0 ? sourceNodeIndex < targetNodeIndex : false;
|
|
if (isFolderDrag) {
|
|
const allowAppendWhenNoTarget = !overTargetId && !resolvedLastOverNode;
|
|
moveFolderByTarget(sourceNode.id, targetNode, movingDown, allowAppendWhenNoTarget);
|
|
} else {
|
|
moveItemByTarget(sourceNode.id, targetNode, movingDown);
|
|
}
|
|
};
|
|
|
|
const openFolderEditor = (folderId) => {
|
|
const entry = localLayout.value.find((e) => e.type === 'folder' && e.id === folderId);
|
|
if (!entry) return;
|
|
folderEditor.isEditing = true;
|
|
folderEditor.editingId = folderId;
|
|
folderEditor.editorType = 'folder';
|
|
folderEditor.data = { id: entry.id, name: entry.name, icon: entry.icon };
|
|
folderEditor.visible = true;
|
|
};
|
|
|
|
const openDashboardEditor = (dashboardKey) => {
|
|
const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, '');
|
|
const dashboard = dashboardStore.getDashboard(dashboardId);
|
|
if (!dashboard) return;
|
|
|
|
folderEditor.isEditing = true;
|
|
folderEditor.editingId = dashboardKey;
|
|
folderEditor.editorType = 'dashboard';
|
|
folderEditor.data = {
|
|
id: dashboardKey,
|
|
name: dashboard.name,
|
|
icon: dashboard.icon || ''
|
|
};
|
|
folderEditor.visible = true;
|
|
};
|
|
|
|
const handleAddFolder = () => {
|
|
folderEditor.isEditing = false;
|
|
folderEditor.editingId = null;
|
|
folderEditor.editorType = 'folder';
|
|
folderEditor.data = {
|
|
id: createFolderId(),
|
|
name: '',
|
|
icon: ''
|
|
};
|
|
folderEditor.visible = true;
|
|
};
|
|
|
|
const handleAddDashboard = async () => {
|
|
const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name'));
|
|
dashboardStore.setEditingDashboardId(dashboard.id);
|
|
localLayout.value.push({
|
|
type: 'item',
|
|
key: `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}`
|
|
});
|
|
localLayout.value = [...localLayout.value];
|
|
emit('dashboard-created', dashboard.id, cloneLayout(localLayout.value), [...hiddenKeySet.value]);
|
|
};
|
|
|
|
const handleFolderEditorSave = async () => {
|
|
if (!folderEditor.data.name?.trim()) return;
|
|
|
|
if (folderEditor.editorType === 'dashboard') {
|
|
const dashboardId = String(folderEditor.editingId || '').replace(DASHBOARD_NAV_KEY_PREFIX, '');
|
|
await dashboardStore.updateDashboard(dashboardId, {
|
|
name: folderEditor.data.name.trim(),
|
|
icon: folderEditor.data.icon?.trim() || DEFAULT_DASHBOARD_ICON
|
|
});
|
|
} else if (folderEditor.isEditing) {
|
|
const entry = localLayout.value.find((e) => e.type === 'folder' && e.id === folderEditor.editingId);
|
|
if (entry) {
|
|
entry.name = folderEditor.data.name.trim();
|
|
entry.nameKey = null;
|
|
entry.icon = folderEditor.data.icon?.trim() || DEFAULT_FOLDER_ICON;
|
|
localLayout.value = [...localLayout.value];
|
|
}
|
|
} else {
|
|
localLayout.value.push({
|
|
type: 'folder',
|
|
id: folderEditor.data.id,
|
|
name: folderEditor.data.name.trim(),
|
|
nameKey: null,
|
|
icon: folderEditor.data.icon?.trim() || DEFAULT_FOLDER_ICON,
|
|
items: []
|
|
});
|
|
localLayout.value = [...localLayout.value];
|
|
if (!expandedKeys.value.includes(folderEditor.data.id)) {
|
|
expandedKeys.value = [...expandedKeys.value, folderEditor.data.id];
|
|
}
|
|
}
|
|
folderEditor.visible = false;
|
|
};
|
|
|
|
const removeFromLayout = (key) => {
|
|
let removed = false;
|
|
for (let i = 0; i < localLayout.value.length; i++) {
|
|
const entry = localLayout.value[i];
|
|
if (entry.type === 'item' && entry.key === key) {
|
|
localLayout.value.splice(i, 1);
|
|
removed = true;
|
|
break;
|
|
}
|
|
if (entry.type === 'folder') {
|
|
const idx = entry.items?.indexOf(key);
|
|
if (idx !== undefined && idx >= 0) {
|
|
entry.items.splice(idx, 1);
|
|
removed = true;
|
|
}
|
|
}
|
|
}
|
|
if (removed) {
|
|
hiddenKeySet.value.delete(key);
|
|
hiddenPlacement.value.delete(key);
|
|
localLayout.value = [...localLayout.value];
|
|
}
|
|
};
|
|
|
|
const handleDeleteDashboard = async (dashboardKey) => {
|
|
const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, '');
|
|
const { ok } = await modalStore.confirm({
|
|
title: t('dashboard.confirmations.delete_title'),
|
|
description: t('dashboard.confirmations.delete_description'),
|
|
destructive: true
|
|
});
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
await dashboardStore.deleteDashboard(dashboardId);
|
|
removeFromLayout(dashboardKey);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const cleanedLayout = localLayout.value.filter(
|
|
(entry) => !(entry.type === 'folder' && (!entry.items || entry.items.length === 0))
|
|
);
|
|
emit('save', cloneLayout(cleanedLayout), [...hiddenKeySet.value]);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
localLayout.value = cloneLayout(props.defaultLayout || []);
|
|
hiddenKeySet.value = new Set(props.defaultHiddenKeys || []);
|
|
hiddenPlacement.value = new Map();
|
|
expandedKeys.value = localLayout.value.filter((e) => e.type === 'folder').map((e) => e.id);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
emit('update:visible', false);
|
|
};
|
|
</script>
|