generated from corrad-software/corrad-af-2024
732 lines
20 KiB
Vue
732 lines
20 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, nextTick, onUnmounted } from 'vue';
|
|
import { useDesignSystem } from '~/composables/useDesignSystem';
|
|
import { useNotifications } from '~/composables/useNotifications';
|
|
import { useTouchInteractions } from '~/composables/useTouchInteractions';
|
|
import LoadingStates from '~/components/base/LoadingStates.vue';
|
|
import ResponsiveContainer from '~/components/base/ResponsiveContainer.vue';
|
|
import BaseModal from '~/components/base/BaseModal.vue';
|
|
import RsButton from '~/components/RsButton.vue';
|
|
|
|
const props = defineProps({
|
|
data: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
loading: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
allowDragDrop: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
allowMultiSelect: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
allowInlineEdit: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
showContextMenu: {
|
|
type: Boolean,
|
|
default: true
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
'node-click', 'node-double-click', 'node-expand', 'node-collapse',
|
|
'node-rename', 'node-delete', 'node-move', 'folder-create', 'file-upload',
|
|
'context-menu', 'selection-change'
|
|
]);
|
|
|
|
// Design system and utilities
|
|
const { tokens, utils, dmsPatterns, statusColors } = useDesignSystem();
|
|
const { success, error, confirm } = useNotifications();
|
|
const { setupTouchInteractions } = useTouchInteractions();
|
|
|
|
// Tree state
|
|
const treeData = ref([]);
|
|
const selectedNodes = ref(new Set());
|
|
const expandedNodes = ref(new Set());
|
|
const editingNode = ref(null);
|
|
const dragNode = ref(null);
|
|
const dropTarget = ref(null);
|
|
const contextMenuNode = ref(null);
|
|
|
|
// UI state
|
|
const showContextMenu = ref(false);
|
|
const contextMenuPosition = ref({ x: 0, y: 0 });
|
|
const showNewFolderDialog = ref(false);
|
|
const showRenameDialog = ref(false);
|
|
const newFolderName = ref('');
|
|
const renameValue = ref('');
|
|
|
|
// Responsive state
|
|
const containerRef = ref(null);
|
|
const isMobile = ref(false);
|
|
|
|
// Build hierarchical tree from flat data
|
|
const buildTreeStructure = (flatData) => {
|
|
const nodeMap = new Map();
|
|
const roots = [];
|
|
|
|
// First pass: create all nodes
|
|
flatData.forEach(item => {
|
|
nodeMap.set(item.id, {
|
|
...item,
|
|
children: [],
|
|
isExpanded: expandedNodes.value.has(item.id),
|
|
isSelected: selectedNodes.value.has(item.id),
|
|
level: 0
|
|
});
|
|
});
|
|
|
|
// Second pass: build hierarchy
|
|
flatData.forEach(item => {
|
|
const node = nodeMap.get(item.id);
|
|
if (item.parentId && nodeMap.has(item.parentId)) {
|
|
const parent = nodeMap.get(item.parentId);
|
|
parent.children.push(node);
|
|
node.level = parent.level + 1;
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
|
|
return roots;
|
|
};
|
|
|
|
// Computed tree structure
|
|
const computedTreeData = computed(() => {
|
|
return buildTreeStructure(props.data);
|
|
});
|
|
|
|
// Update tree data when props change
|
|
watch(() => props.data, (newData) => {
|
|
treeData.value = buildTreeStructure(newData);
|
|
}, { immediate: true });
|
|
|
|
// Node operations
|
|
const toggleNode = async (node) => {
|
|
if (node.type === 'folder') {
|
|
if (expandedNodes.value.has(node.id)) {
|
|
expandedNodes.value.delete(node.id);
|
|
emit('node-collapse', node);
|
|
} else {
|
|
expandedNodes.value.add(node.id);
|
|
emit('node-expand', node);
|
|
}
|
|
|
|
// Rebuild tree to update expansion state
|
|
treeData.value = buildTreeStructure(props.data);
|
|
}
|
|
};
|
|
|
|
const selectNode = (node, multi = false) => {
|
|
if (!multi || !props.allowMultiSelect) {
|
|
selectedNodes.value.clear();
|
|
}
|
|
|
|
if (selectedNodes.value.has(node.id)) {
|
|
selectedNodes.value.delete(node.id);
|
|
} else {
|
|
selectedNodes.value.add(node.id);
|
|
}
|
|
|
|
emit('selection-change', Array.from(selectedNodes.value));
|
|
treeData.value = buildTreeStructure(props.data);
|
|
};
|
|
|
|
const handleNodeClick = (node, event) => {
|
|
const isMultiSelect = event.ctrlKey || event.metaKey;
|
|
selectNode(node, isMultiSelect);
|
|
emit('node-click', node);
|
|
};
|
|
|
|
const handleNodeDoubleClick = (node) => {
|
|
if (node.type === 'folder') {
|
|
toggleNode(node);
|
|
}
|
|
emit('node-double-click', node);
|
|
};
|
|
|
|
// Context menu operations
|
|
const showNodeContextMenu = (node, event) => {
|
|
if (!props.showContextMenu) return;
|
|
|
|
event.preventDefault();
|
|
contextMenuNode.value = node;
|
|
contextMenuPosition.value = {
|
|
x: event.clientX,
|
|
y: event.clientY
|
|
};
|
|
showContextMenu.value = true;
|
|
|
|
emit('context-menu', { node, position: contextMenuPosition.value });
|
|
};
|
|
|
|
const hideContextMenu = () => {
|
|
showContextMenu.value = false;
|
|
contextMenuNode.value = null;
|
|
};
|
|
|
|
// Editing operations
|
|
const startRename = (node) => {
|
|
if (!props.allowInlineEdit) return;
|
|
|
|
editingNode.value = node;
|
|
renameValue.value = node.name;
|
|
showRenameDialog.value = true;
|
|
hideContextMenu();
|
|
};
|
|
|
|
const confirmRename = async () => {
|
|
if (!editingNode.value || !renameValue.value.trim()) return;
|
|
|
|
try {
|
|
await emit('node-rename', {
|
|
node: editingNode.value,
|
|
newName: renameValue.value.trim()
|
|
});
|
|
|
|
success(`Successfully renamed to "${renameValue.value}"`);
|
|
showRenameDialog.value = false;
|
|
editingNode.value = null;
|
|
renameValue.value = '';
|
|
} catch (err) {
|
|
error(`Failed to rename: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
const deleteNode = async (node) => {
|
|
const confirmed = await confirm({
|
|
title: 'Delete Item',
|
|
message: `Are you sure you want to delete "${node.name}"?`,
|
|
dangerous: true
|
|
});
|
|
|
|
if (confirmed) {
|
|
try {
|
|
await emit('node-delete', node);
|
|
selectedNodes.value.delete(node.id);
|
|
success(`Successfully deleted "${node.name}"`);
|
|
} catch (err) {
|
|
error(`Failed to delete: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
hideContextMenu();
|
|
};
|
|
|
|
// Folder operations
|
|
const createNewFolder = () => {
|
|
newFolderName.value = '';
|
|
showNewFolderDialog.value = true;
|
|
hideContextMenu();
|
|
};
|
|
|
|
const confirmCreateFolder = async () => {
|
|
if (!newFolderName.value.trim()) return;
|
|
|
|
try {
|
|
await emit('folder-create', {
|
|
name: newFolderName.value.trim(),
|
|
parentId: contextMenuNode.value?.id || null
|
|
});
|
|
|
|
success(`Folder "${newFolderName.value}" created successfully`);
|
|
showNewFolderDialog.value = false;
|
|
newFolderName.value = '';
|
|
} catch (err) {
|
|
error(`Failed to create folder: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
// Drag and drop
|
|
const handleDragStart = (node, event) => {
|
|
if (!props.allowDragDrop) return;
|
|
|
|
dragNode.value = node;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', node.id);
|
|
};
|
|
|
|
const handleDragOver = (node, event) => {
|
|
if (!props.allowDragDrop || !dragNode.value) return;
|
|
if (node.type !== 'folder' || node.id === dragNode.value.id) return;
|
|
|
|
event.preventDefault();
|
|
dropTarget.value = node;
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
dropTarget.value = null;
|
|
};
|
|
|
|
const handleDrop = async (node, event) => {
|
|
if (!props.allowDragDrop || !dragNode.value) return;
|
|
|
|
event.preventDefault();
|
|
|
|
try {
|
|
await emit('node-move', {
|
|
node: dragNode.value,
|
|
targetParent: node
|
|
});
|
|
|
|
success(`Moved "${dragNode.value.name}" to "${node.name}"`);
|
|
} catch (err) {
|
|
error(`Failed to move: ${err.message}`);
|
|
}
|
|
|
|
dragNode.value = null;
|
|
dropTarget.value = null;
|
|
};
|
|
|
|
// Mobile adaptations
|
|
const setupMobileInteractions = () => {
|
|
if (!containerRef.value) return;
|
|
|
|
const cleanup = setupTouchInteractions(containerRef.value);
|
|
|
|
// Long press for context menu on mobile
|
|
const { onLongPress } = useTouchInteractions();
|
|
onLongPress((state) => {
|
|
const element = document.elementFromPoint(state.x, state.y);
|
|
const nodeElement = element?.closest('[data-node-id]');
|
|
if (nodeElement) {
|
|
const nodeId = nodeElement.dataset.nodeId;
|
|
const node = props.data.find(n => n.id === nodeId);
|
|
if (node) {
|
|
showNodeContextMenu(node, {
|
|
preventDefault: () => {},
|
|
clientX: state.x,
|
|
clientY: state.y
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return cleanup;
|
|
};
|
|
|
|
// File type icon helper
|
|
const getFileIcon = (node) => {
|
|
if (node.type === 'folder') {
|
|
return expandedNodes.value.has(node.id) ? 'mdi:folder-open' : 'mdi:folder';
|
|
}
|
|
|
|
const extension = node.name.split('.').pop()?.toLowerCase();
|
|
return dmsPatterns.fileIcons[extension] || dmsPatterns.fileIcons.default;
|
|
};
|
|
|
|
// Status classes
|
|
const getNodeClasses = (node) => {
|
|
return utils.composeClasses(
|
|
'tree-node flex items-center p-2 rounded-md cursor-pointer transition-all duration-200',
|
|
selectedNodes.value.has(node.id)
|
|
? 'bg-primary/10 dark:bg-primary/20 text-primary'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300',
|
|
dropTarget.value?.id === node.id ? 'bg-blue-100 dark:bg-blue-900/20 border-2 border-blue-400' : '',
|
|
'select-none'
|
|
);
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
isMobile.value = window.innerWidth < 768;
|
|
window.addEventListener('resize', () => {
|
|
isMobile.value = window.innerWidth < 768;
|
|
});
|
|
|
|
// Click outside to hide context menu
|
|
document.addEventListener('click', hideContextMenu);
|
|
|
|
// Setup mobile interactions
|
|
const cleanup = setupMobileInteractions();
|
|
|
|
// Cleanup on unmount
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', hideContextMenu);
|
|
cleanup?.();
|
|
});
|
|
});
|
|
|
|
// Utility functions
|
|
const formatFileSize = (bytes) => {
|
|
if (!bytes || bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
if (!dateString) return '-';
|
|
return new Date(dateString).toLocaleDateString();
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<ResponsiveContainer
|
|
ref="containerRef"
|
|
layout="default"
|
|
padding="md"
|
|
class="windows-explorer-tree h-full"
|
|
>
|
|
<!-- Loading State -->
|
|
<LoadingStates
|
|
v-if="loading"
|
|
type="skeleton-tree"
|
|
:count="5"
|
|
message="Loading file structure..."
|
|
/>
|
|
|
|
<!-- Tree Content -->
|
|
<div v-else class="tree-container h-full flex flex-col">
|
|
<!-- Header Actions -->
|
|
<div class="tree-header mb-4 flex items-center justify-between flex-shrink-0">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
File Explorer
|
|
</h3>
|
|
|
|
<div class="flex items-center space-x-2">
|
|
<!-- New Folder Button -->
|
|
<RsButton
|
|
variant="secondary-outline"
|
|
size="sm"
|
|
@click="createNewFolder"
|
|
:title="isMobile ? 'New Folder' : undefined"
|
|
>
|
|
<Icon name="mdi:folder-plus" class="w-4 h-4" />
|
|
<span v-if="!isMobile" class="ml-2">New Folder</span>
|
|
</RsButton>
|
|
|
|
<!-- Upload Button -->
|
|
<RsButton
|
|
variant="primary"
|
|
size="sm"
|
|
@click="emit('file-upload')"
|
|
:title="isMobile ? 'Upload Files' : undefined"
|
|
>
|
|
<Icon name="mdi:upload" class="w-4 h-4" />
|
|
<span v-if="!isMobile" class="ml-2">Upload</span>
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tree Nodes - Scrollable Container -->
|
|
<div class="tree-nodes flex-1 overflow-auto min-h-0 space-y-1 pr-2">
|
|
<div
|
|
v-for="node in computedTreeData"
|
|
:key="node.id"
|
|
class="tree-node-container"
|
|
>
|
|
<!-- Root Node -->
|
|
<div
|
|
:data-node-id="node.id"
|
|
:class="getNodeClasses(node)"
|
|
:style="{ paddingLeft: `${node.level * 20}px` }"
|
|
:draggable="allowDragDrop"
|
|
@click="handleNodeClick(node, $event)"
|
|
@dblclick="handleNodeDoubleClick(node)"
|
|
@contextmenu="showNodeContextMenu(node, $event)"
|
|
@dragstart="handleDragStart(node, $event)"
|
|
@dragover="handleDragOver(node, $event)"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop(node, $event)"
|
|
>
|
|
<!-- Expand/Collapse Icon -->
|
|
<button
|
|
v-if="node.type === 'folder' && node.children.length > 0"
|
|
@click.stop="toggleNode(node)"
|
|
class="expand-button mr-1 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
<Icon
|
|
:name="expandedNodes.has(node.id) ? 'mdi:chevron-down' : 'mdi:chevron-right'"
|
|
class="w-4 h-4"
|
|
/>
|
|
</button>
|
|
<div v-else class="w-6"></div>
|
|
|
|
<!-- File/Folder Icon -->
|
|
<Icon
|
|
:name="getFileIcon(node)"
|
|
class="w-5 h-5 mr-2 flex-shrink-0"
|
|
:class="node.type === 'folder' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'"
|
|
/>
|
|
|
|
<!-- Node Name -->
|
|
<span class="node-name flex-1 truncate text-sm">
|
|
{{ node.name }}
|
|
</span>
|
|
|
|
<!-- Node Metadata -->
|
|
<div class="node-metadata flex items-center space-x-2 text-xs text-gray-500">
|
|
<span v-if="node.size && node.type !== 'folder'">
|
|
{{ formatFileSize(node.size) }}
|
|
</span>
|
|
<span v-if="node.modifiedAt">
|
|
{{ formatDate(node.modifiedAt) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Child Nodes (Recursive) -->
|
|
<div
|
|
v-if="node.type === 'folder' && expandedNodes.has(node.id)"
|
|
class="child-nodes ml-4"
|
|
>
|
|
<div
|
|
v-for="child in node.children"
|
|
:key="child.id"
|
|
:data-node-id="child.id"
|
|
:class="getNodeClasses(child)"
|
|
:style="{ paddingLeft: `${(child.level - 1) * 20}px` }"
|
|
:draggable="allowDragDrop"
|
|
@click="handleNodeClick(child, $event)"
|
|
@dblclick="handleNodeDoubleClick(child)"
|
|
@contextmenu="showNodeContextMenu(child, $event)"
|
|
@dragstart="handleDragStart(child, $event)"
|
|
@dragover="handleDragOver(child, $event)"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop(child, $event)"
|
|
>
|
|
<div class="w-6"></div>
|
|
<Icon
|
|
:name="getFileIcon(child)"
|
|
class="w-5 h-5 mr-2 flex-shrink-0"
|
|
:class="child.type === 'folder' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'"
|
|
/>
|
|
<span class="node-name flex-1 truncate text-sm">
|
|
{{ child.name }}
|
|
</span>
|
|
<div class="node-metadata flex items-center space-x-2 text-xs text-gray-500">
|
|
<span v-if="child.size && child.type !== 'folder'">
|
|
{{ formatFileSize(child.size) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Context Menu -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="showContextMenu && contextMenuNode"
|
|
class="context-menu fixed z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg py-2 min-w-[160px]"
|
|
:style="{
|
|
left: `${contextMenuPosition.x}px`,
|
|
top: `${contextMenuPosition.y}px`
|
|
}"
|
|
>
|
|
<button
|
|
@click="handleNodeDoubleClick(contextMenuNode)"
|
|
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
|
>
|
|
<Icon name="mdi:open-in-new" class="w-4 h-4 mr-2" />
|
|
Open
|
|
</button>
|
|
|
|
<button
|
|
v-if="allowInlineEdit"
|
|
@click="startRename(contextMenuNode)"
|
|
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
|
>
|
|
<Icon name="mdi:pencil" class="w-4 h-4 mr-2" />
|
|
Rename
|
|
</button>
|
|
|
|
<button
|
|
@click="deleteNode(contextMenuNode)"
|
|
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 flex items-center"
|
|
>
|
|
<Icon name="mdi:delete" class="w-4 h-4 mr-2" />
|
|
Delete
|
|
</button>
|
|
|
|
<hr class="my-1 border-gray-200 dark:border-gray-600">
|
|
|
|
<button
|
|
v-if="contextMenuNode.type === 'folder'"
|
|
@click="createNewFolder"
|
|
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
|
>
|
|
<Icon name="mdi:folder-plus" class="w-4 h-4 mr-2" />
|
|
New Folder
|
|
</button>
|
|
|
|
<button
|
|
@click="emit('file-upload', contextMenuNode)"
|
|
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
|
>
|
|
<Icon name="mdi:upload" class="w-4 h-4 mr-2" />
|
|
Upload Files
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- New Folder Dialog -->
|
|
<BaseModal
|
|
v-model:visible="showNewFolderDialog"
|
|
title="Create New Folder"
|
|
size="md"
|
|
@confirm="confirmCreateFolder"
|
|
@cancel="showNewFolderDialog = false"
|
|
>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Folder Name
|
|
</label>
|
|
<input
|
|
v-model="newFolderName"
|
|
type="text"
|
|
placeholder="Enter folder name..."
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
@keyup.enter="confirmCreateFolder"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</BaseModal>
|
|
|
|
<!-- Rename Dialog -->
|
|
<BaseModal
|
|
v-model:visible="showRenameDialog"
|
|
title="Rename Item"
|
|
size="md"
|
|
@confirm="confirmRename"
|
|
@cancel="showRenameDialog = false"
|
|
>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
New Name
|
|
</label>
|
|
<input
|
|
v-model="renameValue"
|
|
type="text"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
@keyup.enter="confirmRename"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</BaseModal>
|
|
</ResponsiveContainer>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.windows-explorer-tree {
|
|
@apply h-full overflow-hidden;
|
|
}
|
|
|
|
.tree-container {
|
|
@apply h-full flex flex-col min-h-0;
|
|
}
|
|
|
|
.tree-nodes {
|
|
@apply flex-1 overflow-y-auto min-h-0;
|
|
/* Ensure smooth scrolling */
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.tree-node {
|
|
@apply relative;
|
|
}
|
|
|
|
.tree-node.dragging {
|
|
@apply opacity-50;
|
|
}
|
|
|
|
.tree-node.drop-target {
|
|
@apply bg-blue-100 dark:bg-blue-900/20 border-2 border-blue-400 border-dashed;
|
|
}
|
|
|
|
.expand-button {
|
|
@apply transition-transform duration-200;
|
|
}
|
|
|
|
.context-menu {
|
|
animation: contextMenuFadeIn 0.15s ease-out;
|
|
}
|
|
|
|
@keyframes contextMenuFadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: scale(0.95);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.context-menu-item {
|
|
@apply transition-colors duration-150;
|
|
}
|
|
|
|
/* Mobile optimizations */
|
|
@media (max-width: 768px) {
|
|
.tree-node {
|
|
@apply p-3;
|
|
}
|
|
|
|
.node-metadata {
|
|
@apply hidden;
|
|
}
|
|
|
|
.expand-button {
|
|
@apply p-2;
|
|
}
|
|
}
|
|
|
|
/* Enhanced scrollbar styles */
|
|
.tree-nodes::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.tree-nodes::-webkit-scrollbar-track {
|
|
background: #f1f5f9;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.tree-nodes::-webkit-scrollbar-thumb {
|
|
background-color: #cbd5e1;
|
|
border-radius: 4px;
|
|
border: 2px solid #f1f5f9;
|
|
}
|
|
|
|
.tree-nodes::-webkit-scrollbar-thumb:hover {
|
|
background-color: #94a3b8;
|
|
}
|
|
|
|
.tree-nodes::-webkit-scrollbar-corner {
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
/* Dark mode scrollbar */
|
|
.dark .tree-nodes::-webkit-scrollbar-track {
|
|
background: #374151;
|
|
}
|
|
|
|
.dark .tree-nodes::-webkit-scrollbar-thumb {
|
|
background-color: #6b7280;
|
|
border-color: #374151;
|
|
}
|
|
|
|
.dark .tree-nodes::-webkit-scrollbar-thumb:hover {
|
|
background-color: #9ca3af;
|
|
}
|
|
|
|
.dark .tree-nodes::-webkit-scrollbar-corner {
|
|
background: #374151;
|
|
}
|
|
</style> |