EDMS/pages/dms/index.vue
shb 274b1cf693 Linked navigation pane to API
The navigation pane now matches the document structure from database, but breadcrumbs bar became broken, not yet fixed.

Added check to folder creation so that subfolders cannot be created in parent folders that don't exist.
2025-06-19 11:24:19 +08:00

2792 lines
89 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
// Router
const router = useRouter();
// Define page metadata
definePageMeta({
title: "Document Management System",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "DMS",
path: "/dms",
},
],
});
// Import new dialog components
import UploadWithMetadataModal from '~/components/dms/dialogs/UploadWithMetadataModal.vue';
import CreateNewDialog from '~/components/dms/dialogs/CreateNewDialog.vue';
import ContextMenu from '~/components/dms/ui/ContextMenu.vue';
import RsButton from '~/components/RsButton.vue';
import DocumentPreviewDialog from '~/components/dms/preview/DocumentPreviewDialog.vue';
// Core state management
const activeTab = ref('all');
const isLoading = ref(true);
const hasError = ref(false);
const errorMessage = ref('');
const componentsLoaded = ref(false);
const isMobileView = ref(false);
// Navigation state (Windows Explorer style)
const currentPath = ref('/');
const navigationHistory = ref(['/']);
const historyIndex = ref(0);
const breadcrumbPath = ref([]);
const selectedItem = ref(null);
const selectedItems = ref(new Set());
// View state
const viewMode = ref('list'); // 'list' | 'grid' | 'compact'
const sortBy = ref('name');
const sortOrder = ref('asc');
const showPreviewPanel = ref(true);
const previewContent = ref(null);
// Search and filter state
const searchQuery = ref('');
const searchSuggestions = ref([]);
const showSearchSuggestions = ref(false);
const recentSearches = ref(['Budget 2024', 'Annual Report', 'Project Files']);
// Tree view state for collapse/expand
const expandedItems = ref(new Set(['cabinet-1', 'cabinet-2'])); // Start with cabinets expanded
// Toggle expand/collapse for tree items
const toggleItemExpanded = (itemId, event) => {
if (event) {
event.stopPropagation();
}
if (expandedItems.value.has(itemId)) {
expandedItems.value.delete(itemId);
} else {
expandedItems.value.add(itemId);
}
};
// Check if item is expanded
const isItemExpanded = (itemId) => {
return expandedItems.value.has(itemId);
};
// Dialog states
const showUploadModal = ref(false);
const showCreateNewDialog = ref(false);
const showContextMenu = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const contextMenuTarget = ref(null);
// Document preview dialog state
const showDocumentPreview = ref(false);
const previewDocument = ref(null);
const previewMode = ref('view');
// Hover thumbnail state
const showHoverThumbnail = ref(false);
const hoverThumbnailDocument = ref(null);
const hoverThumbnailPosition = ref({ x: 0, y: 0 });
const thumbnailHoverTimeout = ref(null);
// Drag and drop state
const isDragging = ref(false);
const draggedItems = ref([]);
const dropTarget = ref(null);
// Enhanced hierarchical structure (Cabinet > Drawer > Folder structure)
const documentStructure = ref([
// {
// id: 'cabinet-1',
// name: 'JKR Document Cabinet',
// type: 'cabinet',
// path: '/jkr-cabinet',
// accessLevel: 'department',
// itemCount: 234,
// children: [
// {
// id: 'drawer-1-1',
// name: 'Administrative Drawer',
// type: 'drawer',
// path: '/jkr-cabinet/administrative',
// accessLevel: 'department',
// itemCount: 89,
// children: [
// {
// id: 'folder-1-1-1',
// name: 'Personnel Files',
// type: 'folder',
// path: '/jkr-cabinet/administrative/personnel',
// accessLevel: 'private',
// itemCount: 45,
// children: []
// },
// {
// id: 'folder-1-1-2',
// name: 'Budget Reports',
// type: 'folder',
// path: '/jkr-cabinet/administrative/budget',
// accessLevel: 'department',
// itemCount: 23,
// children: []
// }
// ]
// },
// {
// id: 'drawer-1-2',
// name: 'Project Drawer',
// type: 'drawer',
// path: '/jkr-cabinet/projects',
// accessLevel: 'department',
// itemCount: 145,
// children: [
// {
// id: 'folder-1-2-1',
// name: 'Highway Projects',
// type: 'folder',
// path: '/jkr-cabinet/projects/highway',
// accessLevel: 'department',
// itemCount: 67,
// children: []
// },
// {
// id: 'folder-1-2-2',
// name: 'Building Projects',
// type: 'folder',
// path: '/jkr-cabinet/projects/building',
// accessLevel: 'department',
// itemCount: 78,
// children: []
// }
// ]
// }
// ]
// },
// {
// id: 'cabinet-2',
// name: 'Public Documents Cabinet',
// type: 'cabinet',
// path: '/public-cabinet',
// accessLevel: 'public',
// itemCount: 156,
// children: [
// {
// id: 'drawer-2-1',
// name: 'Forms & Applications',
// type: 'drawer',
// path: '/public-cabinet/forms',
// accessLevel: 'public',
// itemCount: 89,
// children: []
// },
// {
// id: 'drawer-2-2',
// name: 'Public Announcements',
// type: 'drawer',
// path: '/public-cabinet/announcements',
// accessLevel: 'public',
// itemCount: 67,
// children: []
// }
// ]
// },
// {
// id: 'cabinet-3',
// name: 'Personal Documents',
// type: 'cabinet',
// path: '/personal-cabinet',
// accessLevel: 'personal',
// itemCount: 45,
// children: [
// {
// id: 'drawer-3-1',
// name: 'My Documents',
// type: 'drawer',
// path: '/personal-cabinet/my-docs',
// accessLevel: 'personal',
// itemCount: 25,
// children: []
// },
// {
// id: 'drawer-3-2',
// name: 'Private Files',
// type: 'drawer',
// path: '/personal-cabinet/private',
// accessLevel: 'private',
// itemCount: 20,
// children: []
// }
// ]
// }
]);
// Current folder contents (what's displayed in the main view)
const currentFolderContents = ref([]);
// Undo/Redo system
const operationHistory = ref([]);
const historyPointer = ref(-1);
// Enhanced tab definitions
const tabs = [
{
id: 'all',
label: 'All Documents',
icon: 'mdi:folder-multiple',
color: 'blue',
description: 'All accessible documents',
count: computed(() => getTotalItemCount())
},
{
id: 'public',
label: 'Public',
icon: 'mdi:earth',
color: 'green',
description: 'Publicly accessible documents',
count: computed(() => getFilteredItemCount('public'))
},
{
id: 'private',
label: 'Private',
icon: 'mdi:lock',
color: 'red',
description: 'Private access only',
count: computed(() => getFilteredItemCount('private'))
},
{
id: 'personal',
label: 'Personal',
icon: 'mdi:account',
color: 'purple',
description: 'Your personal documents',
count: computed(() => getFilteredItemCount('personal'))
}
];
// File type handling (Windows Explorer style)
const getFileTypeIcon = (fileName) => {
if (!fileName) return 'mdi:file-document';
const extension = fileName.split('.').pop()?.toLowerCase();
const iconMap = {
pdf: 'mdi:file-pdf-box',
doc: 'mdi:file-word-box',
docx: 'mdi:file-word-box',
xls: 'mdi:file-excel-box',
xlsx: 'mdi:file-excel-box',
ppt: 'mdi:file-powerpoint-box',
pptx: 'mdi:file-powerpoint-box',
txt: 'mdi:file-document-outline',
jpg: 'mdi:file-image',
jpeg: 'mdi:file-image',
png: 'mdi:file-image',
gif: 'mdi:file-image',
zip: 'mdi:zip-box',
rar: 'mdi:archive',
mp4: 'mdi:file-video',
avi: 'mdi:file-video',
mp3: 'mdi:file-music',
wav: 'mdi:file-music',
default: 'mdi:file-document'
};
return iconMap[extension] || iconMap.default;
};
const getFileTypeColor = (fileName) => {
if (!fileName) return 'text-gray-500 dark:text-gray-400';
const extension = fileName.split('.').pop()?.toLowerCase();
const colorMap = {
pdf: 'text-red-600 dark:text-red-400',
doc: 'text-blue-600 dark:text-blue-400',
docx: 'text-blue-600 dark:text-blue-400',
xls: 'text-green-600 dark:text-green-400',
xlsx: 'text-green-600 dark:text-green-400',
ppt: 'text-orange-600 dark:text-orange-400',
pptx: 'text-orange-600 dark:text-orange-400',
txt: 'text-gray-600 dark:text-gray-400',
jpg: 'text-purple-600 dark:text-purple-400',
jpeg: 'text-purple-600 dark:text-purple-400',
png: 'text-purple-600 dark:text-purple-400',
gif: 'text-purple-600 dark:text-purple-400',
zip: 'text-yellow-600 dark:text-yellow-400',
rar: 'text-yellow-600 dark:text-yellow-400',
mp4: 'text-indigo-600 dark:text-indigo-400',
avi: 'text-indigo-600 dark:text-indigo-400',
mp3: 'text-pink-600 dark:text-pink-400',
wav: 'text-pink-600 dark:text-pink-400',
default: 'text-gray-500 dark:text-gray-400'
};
return colorMap[extension] || colorMap.default;
};
// Function to build path from parent_id chain
const buildPathFromParentId = (item, allItems) => {
const pathSegments = [];
let currentItem = item;
// Traverse up the parent chain until we reach a root item (null parent_id)
while (currentItem) {
// Add the current item's name to the start of the path
pathSegments.unshift(currentItem.name.toLowerCase().replace(/\s+/g, '-'));
// If we've reached a root item (null parent_id), break the loop
if (!currentItem.parent_id) break;
// Find the parent item
currentItem = allItems.find(i => i.id === currentItem.parent_id);
// Break if we can't find the parent (shouldn't happen in a valid tree)
if (!currentItem) break;
}
// Construct the final path with leading slash
return '/' + pathSegments.join('/');
};
// Function to convert database records to frontend structure
const convertDbToFrontendStructure = (dbRecords) => {
// First pass: Create a map of all items with their paths
const itemsWithPaths = dbRecords.folders.map(record => ({
id: record.cb_id,
name: record.cb_name,
type: record.cb_type || 'folder',
parent_id: record.cb_parent_id,
accessLevel: record.cb_access_level || 'public',
itemCount: record.item_count || 0,
children: [],
path: '' // Will be populated in the next step
}));
// Second pass: Build paths for all items
itemsWithPaths.forEach(item => {
item.path = buildPathFromParentId(item, itemsWithPaths);
});
// Third pass: Build the tree structure
const rootItems = itemsWithPaths.filter(item => !item.parent_id);
// Recursive function to build the tree
const buildTree = (items) => {
return items.map(item => {
const children = itemsWithPaths.filter(child => child.parent_id === item.id);
return {
...item,
children: children.length > 0 ? buildTree(children) : []
};
});
};
return buildTree(rootItems);
};
const mapAPIToDocumentStructure = async () => {
try {
const response = await fetch('/api/dms/folder');
const dbRecords = await response.json();
console.log(dbRecords);
// Convert database records to frontend structure
documentStructure.value = convertDbToFrontendStructure(dbRecords);
return documentStructure.value;
} catch (error) {
console.error('Error mapping API data:', error);
return [];
}
};
// Navigation functions
const buildBreadcrumbs = async (path) => {
if (path === '/') {
return [{ name: 'Root', path: '/', type: 'root' }];
}
const crumbs = [{ name: 'Root', path: '/', type: 'root' }];
const segments = path.split('/').filter(Boolean);
documentStructure.value = await mapAPIToDocumentStructure();
console.log(documentStructure.value);
let itemsToSearch = documentStructure.value;
console.log(itemsToSearch);
let currentPath = '';
for (const segment of segments) {
currentPath += '/' + segment;
const isApiStructure = itemsToSearch.length > 0 && 'cb_id' in itemsToSearch[0];
// Slugify function to match path segments with names
const slugify = (text) => text.toString().toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '');
const foundItem = itemsToSearch.find(item => {
const itemName = isApiStructure ? item.cabinet_name : item.name;
if (!itemName) return false;
if (item.path) {
// Prefer matching with existing path property
return item.path === currentPath;
}
// Fallback to comparing slugified names
return slugify(itemName) === segment;
});
if (foundItem) {
const itemName = isApiStructure ? foundItem.cabinet_name : foundItem.name;
const itemPath = foundItem.path || currentPath;
const itemType = foundItem.type || 'folder';
crumbs.push({
name: itemName,
path: itemPath,
type: itemType
});
itemsToSearch = foundItem.children || [];
} else {
// If an item is not found in the structure, use the segment as its name
// This can happen for paths pointing to files or during transitions
crumbs.push({
name: segment,
path: currentPath,
type: 'folder'
});
itemsToSearch = []; // Stop searching deeper
}
}
return crumbs;
};
const navigateToPath = (path, addToHistory = true) => {
if (addToHistory && path !== currentPath.value) {
addToNavigationHistory(path);
}
currentPath.value = path;
breadcrumbPath.value = buildBreadcrumbs(path);
loadFolderContents(path);
selectedItem.value = null;
selectedItems.value.clear();
};
const addToNavigationHistory = (path) => {
// Remove any history after current index
navigationHistory.value = navigationHistory.value.slice(0, historyIndex.value + 1);
navigationHistory.value.push(path);
historyIndex.value = navigationHistory.value.length - 1;
// Limit history size
if (navigationHistory.value.length > 50) {
navigationHistory.value.shift();
historyIndex.value--;
}
};
const canGoBack = computed(() => historyIndex.value > 0);
const canGoForward = computed(() => historyIndex.value < navigationHistory.value.length - 1);
const goBack = () => {
if (canGoBack.value) {
historyIndex.value--;
navigateToPath(navigationHistory.value[historyIndex.value], false);
}
};
const goForward = () => {
if (canGoForward.value) {
historyIndex.value++;
navigateToPath(navigationHistory.value[historyIndex.value], false);
}
};
const goUp = () => {
const segments = currentPath.value.split('/').filter(Boolean);
if (segments.length > 0) {
segments.pop();
const parentPath = segments.length > 0 ? '/' + segments.join('/') : '/';
navigateToPath(parentPath);
}
};
// Content loading and management
const loadFolderContents = async (path) => {
isLoading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 200));
const item = findItemByPath(path, documentStructure.value);
if (item && item.children) {
currentFolderContents.value = item.children.map(child => ({
...child,
lastModified: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
size: generateFileSize(child.type),
owner: 'System Admin'
}));
} else {
// Mock files for leaf folders
currentFolderContents.value = generateMockFiles(path);
}
// Apply current filter
if (activeTab.value !== 'all') {
currentFolderContents.value = currentFolderContents.value.filter(item =>
item.accessLevel === activeTab.value ||
(item.tags && item.tags.some(tag => tag.toLowerCase() === activeTab.value))
);
}
} catch (err) {
console.error('Failed to load folder contents:', err);
hasError.value = true;
errorMessage.value = 'Failed to load folder contents';
} finally {
isLoading.value = false;
}
};
const findItemByPath = (path, items = documentStructure.value) => {
// Check if we're using the API-based structure or mock structure
const isApiStructure = items.length > 0 && 'cb_id' in items[0];
for (const item of items) {
const itemPath = isApiStructure ? (item.path || ('/' + (item.cabinet_name || '').toString().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]+/g, ''))) : item.path;
if (itemPath === path) {
return item;
}
// Check children if they exist and path might be inside this subtree
if (item.children && path.startsWith(itemPath + (itemPath === '/' ? '' : '/'))) {
const found = findItemByPath(path, item.children);
if (found) return found;
}
}
return null;
};
const generateMockFiles = (path) => {
const files = [
{
id: 'file-1',
name: 'Annual Report 2024.pdf',
type: 'file',
accessLevel: 'department',
tags: ['Annual', 'Report'],
description: 'Comprehensive annual report for 2024'
},
{
id: 'file-2',
name: 'Budget Analysis.xlsx',
type: 'file',
accessLevel: 'private',
tags: ['Budget', 'Financial'],
description: 'Detailed budget analysis spreadsheet'
},
{
id: 'file-3',
name: 'Project Proposal.docx',
type: 'file',
accessLevel: 'public',
tags: ['Project', 'Proposal'],
description: 'New project proposal document'
}
];
return files.map(file => ({
...file,
path: `${path}/${file.name}`,
lastModified: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
size: generateFileSize('file'),
owner: 'System Admin'
}));
};
const generateFileSize = (type) => {
if (type === 'file') {
const sizes = ['1.2 MB', '3.4 MB', '567 KB', '2.1 MB', '890 KB'];
return sizes[Math.floor(Math.random() * sizes.length)];
}
return null;
};
// Item count functions
const getTotalItemCount = () => {
const countItems = (items) => {
let count = 0;
for (const item of items) {
count++;
if (item.children) {
count += countItems(item.children);
}
}
return count;
};
return countItems(documentStructure.value);
};
const getFilteredItemCount = (filter) => {
const countFilteredItems = (items) => {
let count = 0;
for (const item of items) {
if (item.accessLevel === filter ||
(item.tags && item.tags.some(tag => tag.toLowerCase() === filter))) {
count++;
}
if (item.children) {
count += countFilteredItems(item.children);
}
}
return count;
};
return countFilteredItems(documentStructure.value);
};
// All the other functions that we need for the template
const selectItem = (item) => {
selectedItem.value = item;
if (item.type === 'file') {
loadPreview(item);
} else {
previewContent.value = null;
}
};
const toggleItemSelection = (item) => {
if (selectedItems.value.has(item.id)) {
selectedItems.value.delete(item.id);
} else {
selectedItems.value.add(item.id);
}
};
const selectAllItems = () => {
currentFolderContents.value.forEach(item => {
selectedItems.value.add(item.id);
});
};
const clearSelection = () => {
selectedItems.value.clear();
selectedItem.value = null;
previewContent.value = null;
};
const loadPreview = async (item) => {
if (item.type !== 'file') return;
try {
const extension = item.name.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) {
previewContent.value = {
type: 'image',
url: `/api/files/${item.id}/preview`,
metadata: {
dimensions: '1920x1080',
fileSize: item.size,
colorSpace: 'RGB'
}
};
} else if (extension === 'pdf') {
previewContent.value = {
type: 'pdf',
url: `/api/files/${item.id}/preview`,
metadata: {
pages: 12,
fileSize: item.size,
version: '1.4'
}
};
} else if (['txt', 'md'].includes(extension)) {
previewContent.value = {
type: 'text',
content: 'Sample text content...',
metadata: {
lines: 150,
words: 1250,
characters: 7500
}
};
} else {
previewContent.value = {
type: 'metadata',
metadata: {
fileSize: item.size,
lastModified: item.lastModified,
owner: item.owner,
tags: item.tags || []
}
};
}
} catch (err) {
console.error('Failed to load preview:', err);
}
};
const handleItemDoubleClick = (item) => {
if (item.type === 'file') {
openDocumentPreview(item);
} else {
navigateToPath(item.path);
}
};
const changeTab = (tabId) => {
activeTab.value = tabId;
loadFolderContents(currentPath.value);
};
const handleDragStart = (event, item) => {
isDragging.value = true;
draggedItems.value = selectedItems.value.has(item.id)
? Array.from(selectedItems.value).map(id => currentFolderContents.value.find(i => i.id === id))
: [item];
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', JSON.stringify(draggedItems.value));
};
const handleDragOver = (event, item) => {
if (item.type === 'folder' || item.type === 'drawer' || item.type === 'cabinet') {
event.preventDefault();
dropTarget.value = item;
}
};
const handleDragLeave = () => {
dropTarget.value = null;
};
const handleDrop = (event, targetItem) => {
event.preventDefault();
if (targetItem.type === 'folder' || targetItem.type === 'drawer' || targetItem.type === 'cabinet') {
const items = JSON.parse(event.dataTransfer.getData('text/plain'));
moveItems(items, targetItem);
}
isDragging.value = false;
dropTarget.value = null;
draggedItems.value = [];
};
const moveItems = (items, target) => {
console.log('Moving items:', items, 'to:', target.name);
addOperation('move', { items, target, from: currentPath.value });
};
const addOperation = (type, data) => {
const operation = {
type,
data,
timestamp: Date.now(),
path: currentPath.value
};
operationHistory.value = operationHistory.value.slice(0, historyPointer.value + 1);
operationHistory.value.push(operation);
historyPointer.value = operationHistory.value.length - 1;
if (operationHistory.value.length > 100) {
operationHistory.value.shift();
historyPointer.value--;
}
};
const canUndo = computed(() => historyPointer.value >= 0);
const canRedo = computed(() => historyPointer.value < operationHistory.value.length - 1);
const undo = () => {
if (canUndo.value) {
const operation = operationHistory.value[historyPointer.value];
performUndo(operation);
historyPointer.value--;
}
};
const redo = () => {
if (canRedo.value) {
historyPointer.value++;
const operation = operationHistory.value[historyPointer.value];
performRedo(operation);
}
};
const performUndo = (operation) => {
console.log('Undoing:', operation.type);
loadFolderContents(currentPath.value);
};
const performRedo = (operation) => {
console.log('Redoing:', operation.type);
loadFolderContents(currentPath.value);
};
const handleKeyboardShortcuts = (event) => {
if (event.altKey && event.key === 'ArrowLeft') {
event.preventDefault();
goBack();
} else if (event.altKey && event.key === 'ArrowRight') {
event.preventDefault();
goForward();
} else if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault();
goUp();
} else if (event.ctrlKey && event.key === 'z' && !event.shiftKey) {
event.preventDefault();
undo();
} else if (event.ctrlKey && (event.key === 'y' || (event.key === 'z' && event.shiftKey))) {
event.preventDefault();
redo();
} else if (event.ctrlKey && event.key === 'u') {
event.preventDefault();
openUploadModal();
} else if (event.ctrlKey && event.shiftKey && event.key === 'n') {
event.preventDefault();
openCreateNewDialog();
} else if (event.ctrlKey && event.key === 'a') {
event.preventDefault();
selectAllItems();
} else if (event.key === 'F2' && selectedItem.value) {
event.preventDefault();
renameItem(selectedItem.value);
} else if (event.key === 'Delete' && selectedItems.value.size > 0) {
event.preventDefault();
deleteSelectedItems();
} else if (event.key === 'Enter' && selectedItem.value) {
event.preventDefault();
handleItemDoubleClick(selectedItem.value);
} else if (event.key === 'Escape') {
event.preventDefault();
clearSelection();
closeAllDialogs();
}
};
const handleContextMenuAction = ({ item, target }) => {
switch (item.id) {
case 'open':
handleItemDoubleClick(target);
break;
case 'rename':
renameItem(target);
break;
case 'delete':
deleteItem(target);
break;
case 'copy':
copyItems([target]);
break;
case 'cut':
cutItems([target]);
break;
case 'paste':
pasteItems();
break;
case 'properties':
showProperties(target);
break;
default:
console.log('Unhandled context menu action:', item.id);
}
};
const renameItem = (item) => {
const newName = prompt('Enter new name:', item.name);
if (newName && newName !== item.name) {
addOperation('rename', { item, oldName: item.name, newName });
item.name = newName;
console.log('Renamed item to:', newName);
}
};
const deleteItem = (item) => {
if (confirm(`Delete "${item.name}"?`)) {
addOperation('delete', { item, path: currentPath.value });
console.log('Deleted item:', item.name);
loadFolderContents(currentPath.value);
}
};
const deleteSelectedItems = () => {
const items = Array.from(selectedItems.value).map(id =>
currentFolderContents.value.find(item => item.id === id)
);
if (confirm(`Delete ${items.length} selected item(s)?`)) {
addOperation('delete', { items, path: currentPath.value });
console.log('Deleted items:', items.map(i => i.name));
clearSelection();
loadFolderContents(currentPath.value);
}
};
const copyItems = (items) => {
console.log('Copied items:', items.map(i => i.name));
};
const cutItems = (items) => {
console.log('Cut items:', items.map(i => i.name));
};
const pasteItems = () => {
console.log('Pasted items to:', currentPath.value);
};
const showProperties = (item) => {
console.log('Show properties for:', item.name);
};
const openUploadModal = () => {
showUploadModal.value = true;
};
const openCreateNewDialog = () => {
showCreateNewDialog.value = true;
};
const closeAllDialogs = () => {
showUploadModal.value = false;
showCreateNewDialog.value = false;
showContextMenu.value = false;
};
// Global click handler to close context menu
const handleGlobalClick = (event) => {
if (showContextMenu.value) {
showContextMenu.value = false;
}
};
// Global right-click handler to close context menu on background
const handleGlobalContextMenu = (event) => {
// Only handle if it's not on a specific item
const target = event.target.closest('[data-context-item]');
if (!target && showContextMenu.value) {
showContextMenu.value = false;
}
};
// Upload and create handlers
const handleUploadComplete = (data) => {
console.log('Upload completed:', data);
addOperation('upload', { files: data.files, path: currentPath.value });
loadFolderContents(currentPath.value);
};
const handleCreateComplete = (data) => {
console.log('Creation completed:', data);
addOperation('create', { item: data, path: currentPath.value });
loadFolderContents(currentPath.value);
};
// Context menu
const handleDocumentContextMenu = (event, document) => {
event.preventDefault();
event.stopPropagation();
// Close any existing context menu first
showContextMenu.value = false;
// Use nextTick to ensure the menu is closed before opening new one
nextTick(() => {
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
contextMenuTarget.value = document;
showContextMenu.value = true;
});
};
const handleTreeContextMenu = ({ event, item }) => {
event.preventDefault();
event.stopPropagation();
// Close any existing context menu first
showContextMenu.value = false;
// Use nextTick to ensure the menu is closed before opening new one
nextTick(() => {
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
contextMenuTarget.value = item;
showContextMenu.value = true;
});
};
// Utility functions
const checkMobileView = () => {
isMobileView.value = window.innerWidth < 768;
};
// Force reactivity update for grid columns on window resize
const updateGridLayout = () => {
// Force a re-computation of gridColumns by triggering reactivity
checkMobileView();
};
const formatFileSize = (size) => {
if (typeof size === 'string') return size;
if (!size) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return `${size.toFixed(1)} ${units[index]}`;
};
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) return 'Yesterday';
if (diffDays <= 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
// Tab styling
const getTabClasses = (tab) => {
const isActive = activeTab.value === tab.id;
if (isActive) {
switch (tab.color) {
case 'blue':
return 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-2 border-blue-300 dark:border-blue-700 shadow-sm';
case 'green':
return 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-2 border-green-300 dark:border-green-700 shadow-sm';
case 'red':
return 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-2 border-red-300 dark:border-red-700 shadow-sm';
case 'purple':
return 'bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 border-2 border-purple-300 dark:border-purple-700 shadow-sm';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-2 border-gray-300 dark:border-gray-600 shadow-sm';
}
} else {
return 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600';
}
};
const getTabIconClasses = (tab) => {
const isActive = activeTab.value === tab.id;
if (isActive) {
switch (tab.color) {
case 'blue':
return 'text-blue-600 dark:text-blue-400';
case 'green':
return 'text-green-600 dark:text-green-400';
case 'red':
return 'text-red-600 dark:text-red-400';
case 'purple':
return 'text-purple-600 dark:text-purple-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
} else {
return 'text-gray-500 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-400';
}
};
// Lifecycle hooks
onMounted(async () => {
checkMobileView();
// Add event listeners
window.addEventListener('resize', updateGridLayout);
window.addEventListener('keydown', handleKeyboardShortcuts);
document.addEventListener('click', handleGlobalClick);
document.addEventListener('contextmenu', handleGlobalContextMenu);
// Populate document structure
await mapAPIToDocumentStructure();
// Initialize navigation
navigateToPath('/', false);
// Set loading to false after initialization
isLoading.value = false;
componentsLoaded.value = true;
});
onUnmounted(() => {
window.removeEventListener('resize', updateGridLayout);
window.removeEventListener('keydown', handleKeyboardShortcuts);
document.removeEventListener('click', handleGlobalClick);
document.removeEventListener('contextmenu', handleGlobalContextMenu);
});
// Utility functions for document status and access level
const getDocumentStatusClass = (document) => {
switch (document.status) {
case 'active':
return 'opacity-100';
case 'pending':
return 'opacity-75 bg-yellow-50 dark:bg-yellow-900/10 border-yellow-200 dark:border-yellow-800';
case 'restricted':
return 'opacity-90 bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800';
default:
return 'opacity-100';
}
};
const getAccessLevelBadge = (accessLevel) => {
switch (accessLevel) {
case 'public':
return { class: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400', text: 'Public' };
case 'department':
return { class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400', text: 'Department' };
case 'restricted':
return { class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400', text: 'Restricted' };
case 'confidential':
return { class: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400', text: 'Confidential' };
default:
return { class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400', text: 'Unknown' };
}
};
// User permissions (can be made dynamic based on user role)
const canEdit = ref(true);
const canDelete = ref(true);
const canShare = ref(true);
// Grid columns computation based on preview panel state and screen size
const gridColumns = computed(() => {
// Base columns when preview panel is closed
const baseColumns = {
sm: 2, // mobile
md: 4, // tablet
lg: 6, // laptop
xl: 8, // desktop
'2xl': 10 // large desktop
};
// Reduced columns when preview panel is open (to maintain thumbnail size)
const reducedColumns = {
sm: 1, // mobile
md: 2, // tablet
lg: 3, // laptop
xl: 4, // desktop
'2xl': 6 // large desktop
};
const columns = showPreviewPanel.value ? reducedColumns : baseColumns;
// Determine screen size (this is a simplified approach)
const width = window.innerWidth;
if (width >= 1536) return columns['2xl'];
if (width >= 1280) return columns.xl;
if (width >= 1024) return columns.lg;
if (width >= 768) return columns.md;
return columns.sm;
});
// Enhanced search functionality
const handleSearch = (query) => {
searchQuery.value = query;
if (query.length > 2) {
// Mock search suggestions
searchSuggestions.value = [
'Budget Reports',
'Annual Budget 2024',
'Budget Analysis'
].filter(s => s.toLowerCase().includes(query.toLowerCase()));
showSearchSuggestions.value = true;
} else {
showSearchSuggestions.value = false;
}
};
// Handle search suggestion selection
const selectSearchSuggestion = (suggestion) => {
searchQuery.value = suggestion;
showSearchSuggestions.value = false;
// Add to recent searches if not already there
if (!recentSearches.value.includes(suggestion)) {
recentSearches.value.unshift(suggestion);
if (recentSearches.value.length > 5) {
recentSearches.value.pop();
}
}
};
// Quick actions
const quickActions = [
{ id: 'view', label: 'View', icon: 'mdi:eye', variant: 'secondary-outline' },
{ id: 'download', label: 'Download', icon: 'mdi:download', variant: 'primary' },
{ id: 'share', label: 'Share', icon: 'mdi:share', variant: 'secondary-outline' },
{ id: 'edit', label: 'Edit', icon: 'mdi:pencil', variant: 'secondary-outline' }
];
// Handle quick action
const handleQuickAction = (action, document) => {
console.log(`${action} action on:`, document.name);
// Implement actual actions here
};
// Document preview functions
const openDocumentPreview = (document, mode = 'view') => {
previewDocument.value = document;
previewMode.value = mode;
showDocumentPreview.value = true;
};
const closeDocumentPreview = () => {
showDocumentPreview.value = false;
previewDocument.value = null;
previewMode.value = 'view';
};
// Hover thumbnail functions with cursor positioning
const showDocumentThumbnail = (event, document) => {
if (document.type !== 'file') return;
clearTimeout(thumbnailHoverTimeout.value);
thumbnailHoverTimeout.value = setTimeout(() => {
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Thumbnail dimensions (approximate)
const thumbnailWidth = 300;
const thumbnailHeight = 200;
// Default offset from cursor
const offsetX = 15;
const offsetY = 15;
let x = event.clientX + offsetX;
let y = event.clientY - offsetY;
// Adjust horizontal position if it would go off-screen
if (x + thumbnailWidth > viewportWidth) {
x = event.clientX - thumbnailWidth - offsetX;
}
// Adjust vertical position if it would go off-screen
if (y < 0) {
y = event.clientY + offsetY;
} else if (y + thumbnailHeight > viewportHeight) {
y = viewportHeight - thumbnailHeight - 10;
}
hoverThumbnailPosition.value = { x, y };
hoverThumbnailDocument.value = document;
showHoverThumbnail.value = true;
}, 500); // 500ms delay before showing thumbnail
};
const hideDocumentThumbnail = () => {
clearTimeout(thumbnailHoverTimeout.value);
showHoverThumbnail.value = false;
hoverThumbnailDocument.value = null;
};
const handleDocumentPreviewAction = (action, document) => {
switch (action) {
case 'view':
openDocumentPreview(document, 'view');
break;
case 'edit':
openDocumentPreview(document, 'edit');
break;
case 'download':
downloadDocument(document);
break;
case 'share':
shareDocument(document);
break;
default:
console.log('Unhandled preview action:', action);
}
};
// Document preview event handlers
const handleDocumentUpdated = (data) => {
console.log('Document updated:', data);
// Update the document in the current folder contents
const index = currentFolderContents.value.findIndex(item => item.id === data.id);
if (index !== -1) {
currentFolderContents.value[index] = { ...currentFolderContents.value[index], ...data };
}
// Add to operation history
addOperation('update', { document: data, path: currentPath.value });
};
const handleAnnotationAdded = (annotation) => {
console.log('Annotation added:', annotation);
// Handle annotation added - could sync with server
};
const handleAnnotationUpdated = (annotation) => {
console.log('Annotation updated:', annotation);
// Handle annotation updated - could sync with server
};
const handleAnnotationDeleted = (annotationId) => {
console.log('Annotation deleted:', annotationId);
// Handle annotation deleted - could sync with server
};
const handleDocumentShared = (shareData) => {
console.log('Document shared:', shareData);
// Handle document sharing - could update permissions
};
const handleTagAdded = (tag) => {
console.log('Tag added:', tag);
// Handle tag added to document
if (previewDocument.value) {
if (!previewDocument.value.tags) {
previewDocument.value.tags = [];
}
previewDocument.value.tags.push(tag);
}
};
const handleTagRemoved = (tag) => {
console.log('Tag removed:', tag);
// Handle tag removed from document
if (previewDocument.value && previewDocument.value.tags) {
previewDocument.value.tags = previewDocument.value.tags.filter(t => t !== tag);
}
};
// Enhanced download and share functions
const downloadDocument = (document) => {
// Create download link
const link = document.createElement('a');
link.href = `/api/documents/${document.id}/download`;
link.download = document.name;
link.click();
console.log('Downloading:', document.name);
// Add to operation history
addOperation('download', { document, path: currentPath.value });
};
const shareDocument = (document) => {
console.log('Sharing:', document.name);
// Implement share functionality - could open share modal
// For now, just show a simple alert
alert(`Share functionality for "${document.name}" would be implemented here.`);
};
// Filtered document structure based on active tab
const filteredDocumentStructure = computed(() => {
if (activeTab.value === 'all') {
return documentStructure.value;
}
return documentStructure.value.filter(cabinet => {
// Check if cabinet or any of its children match the access level
const cabinetMatches = cabinet.accessLevel === activeTab.value;
const hasMatchingChildren = hasAccessLevelInChildren(cabinet, activeTab.value);
return cabinetMatches || hasMatchingChildren;
}).map(cabinet => {
// Filter children recursively
return {
...cabinet,
children: filterChildrenByAccessLevel(cabinet.children, activeTab.value)
};
});
});
const hasAccessLevelInChildren = (item, accessLevel) => {
if (!item.children) return false;
return item.children.some(child =>
child.accessLevel === accessLevel || hasAccessLevelInChildren(child, accessLevel)
);
};
const filterChildrenByAccessLevel = (children, accessLevel) => {
if (!children) return [];
return children.filter(child => {
const childMatches = child.accessLevel === accessLevel;
const hasMatchingChildren = hasAccessLevelInChildren(child, accessLevel);
return childMatches || hasMatchingChildren;
}).map(child => ({
...child,
children: filterChildrenByAccessLevel(child.children, accessLevel)
}));
};
</script>
<template>
<div class="dms-explorer h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
<!-- Windows Explorer-style Title Bar -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Icon name="mdi:folder-multiple" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Document Management System</h1>
</div>
<div class="flex items-center space-x-2">
<button
@click="showPreviewPanel = !showPreviewPanel"
:class="[
'px-2 py-1 text-sm rounded-md transition-colors',
showPreviewPanel
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
]"
title="Toggle preview panel"
>
<Icon name="mdi:dock-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Navigation Toolbar -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2">
<div class="flex items-center space-x-3">
<!-- Navigation Buttons -->
<div class="flex items-center space-x-0.5">
<button
@click="goBack"
:disabled="!canGoBack"
class="p-1.5 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Back (Alt+←)"
>
<Icon name="mdi:arrow-left" class="w-4 h-4" />
</button>
<button
@click="goForward"
:disabled="!canGoForward"
class="p-1.5 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Forward (Alt+→)"
>
<Icon name="mdi:arrow-right" class="w-4 h-4" />
</button>
<button
@click="goUp"
:disabled="currentPath === '/'"
class="p-1.5 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Up (Alt+↑)"
>
<Icon name="mdi:arrow-up" class="w-4 h-4" />
</button>
</div>
<!-- Breadcrumb Navigation -->
<div class="flex-1 bg-gray-50 dark:bg-gray-700 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-600">
<nav class="flex items-center space-x-1 text-sm">
<button
v-for="(crumb, index) in breadcrumbPath"
:key="index"
@click="navigateToPath(crumb.path)"
:class="[
'px-1.5 py-0.5 rounded-md transition-colors flex items-center space-x-1',
index === breadcrumbPath.length - 1
? 'text-gray-900 dark:text-gray-100 font-medium bg-white dark:bg-gray-600'
: 'text-blue-600 dark:text-blue-400 hover:bg-white dark:hover:bg-gray-600'
]"
>
<Icon
:name="crumb.type === 'root' ? 'mdi:home' :
crumb.type === 'cabinet' ? 'mdi:archive' :
crumb.type === 'drawer' ? 'mdi:folder-multiple' :
'mdi:folder'"
class="w-3 h-3"
/>
<span>{{ crumb.name }}</span>
</button>
<Icon
v-if="index < breadcrumbPath.length - 1"
name="mdi:chevron-right"
class="w-3 h-3 text-gray-400"
/>
</nav>
</div>
<!-- Search Box -->
<div class="relative w-72">
<div class="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
<Icon name="mdi:magnify" class="h-4 w-4 text-gray-400" />
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search documents..."
class="block w-full pl-9 pr-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Undo/Redo Buttons -->
<div class="flex items-center space-x-0.5">
<button
@click="undo"
:disabled="!canUndo"
class="p-1.5 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Undo (Ctrl+Z)"
>
<Icon name="mdi:undo" class="w-4 h-4" />
</button>
<button
@click="redo"
:disabled="!canRedo"
class="p-1.5 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Redo (Ctrl+Y)"
>
<Icon name="mdi:redo" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="flex flex-1 min-h-0">
<!-- Left Sidebar - Folder Tree -->
<div class="w-72 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<!-- Access Filter Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 p-2">
<h3 class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-1.5 uppercase tracking-wider">
Access Filters
</h3>
<div class="flex flex-wrap gap-1">
<button
v-for="tab in tabs"
:key="tab.id"
@click="changeTab(tab.id)"
:class="[
'flex items-center px-1.5 py-1 text-xs font-medium rounded-md transition-all duration-200',
getTabClasses(tab)
]"
:title="tab.description"
>
<Icon :name="tab.icon" :class="['w-3 h-3 mr-1', getTabIconClasses(tab)]" />
<span>{{ tab.label }}</span>
<span class="ml-1 text-xs opacity-75">
({{ typeof tab.count === 'function' ? tab.count() : tab.count }})
</span>
</button>
</div>
</div>
<!-- Document Tree Structure -->
<div class="flex-1 overflow-auto p-2">
<h3 class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-1.5 uppercase tracking-wider">
Document Structure
</h3>
<div class="space-y-0.5">
<div
v-for="item in filteredDocumentStructure"
:key="item.id"
class="relative"
>
<div
data-context-item
@click="navigateToPath(item.path)"
@contextmenu="handleTreeContextMenu({ event: $event, item })"
:class="[
'flex items-center p-1 text-sm rounded-md cursor-pointer transition-all duration-200 group',
'hover:bg-gray-100 dark:hover:bg-gray-700',
currentPath === item.path
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-700 dark:text-gray-300'
]"
>
<!-- Expand/Collapse Button -->
<button
v-if="item.children && item.children.length > 0"
@click="toggleItemExpanded(item.id, $event)"
class="flex-shrink-0 w-4 h-4 mr-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center"
>
<Icon
:name="isItemExpanded(item.id) ? 'mdi:chevron-down' : 'mdi:chevron-right'"
class="w-3 h-3"
/>
</button>
<div v-else class="w-5"></div>
<!-- Folder Icon -->
<Icon
name="mdi:folder"
class="w-4 h-4 mr-1.5 flex-shrink-0 text-blue-600 dark:text-blue-400"
/>
<!-- Item Name -->
<span class="flex-1 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.name }}
</span>
<!-- Item Count -->
<span
v-if="item.itemCount"
class="text-xs text-gray-500 dark:text-gray-400 ml-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
{{ item.itemCount }}
</span>
</div>
<!-- Nested children with expand/collapse functionality -->
<div v-if="item.children && isItemExpanded(item.id)" class="ml-3 mt-0.5 space-y-0.5">
<div
v-for="child in item.children"
:key="child.id"
class="relative"
>
<div
data-context-item
@click="navigateToPath(child.path)"
@contextmenu="handleTreeContextMenu({ event: $event, item: child })"
:class="[
'flex items-center p-1 text-sm rounded-md cursor-pointer transition-all duration-200 group',
'hover:bg-gray-100 dark:hover:bg-gray-700',
currentPath === child.path
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-700 dark:text-gray-300'
]"
>
<!-- Expand/Collapse Button for child -->
<button
v-if="child.children && child.children.length > 0"
@click="toggleItemExpanded(child.id, $event)"
class="flex-shrink-0 w-4 h-4 mr-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center"
>
<Icon
:name="isItemExpanded(child.id) ? 'mdi:chevron-down' : 'mdi:chevron-right'"
class="w-3 h-3"
/>
</button>
<div v-else class="w-5"></div>
<Icon
name="mdi:folder"
class="w-4 h-4 mr-1.5 flex-shrink-0 text-blue-600 dark:text-blue-400"
/>
<span class="flex-1 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ child.name }}
</span>
<span
v-if="child.itemCount"
class="text-xs text-gray-500 dark:text-gray-400 ml-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
{{ child.itemCount }}
</span>
</div>
<!-- Third level children -->
<div v-if="child.children && isItemExpanded(child.id)" class="ml-3 mt-0.5 space-y-0.5">
<div
v-for="grandchild in child.children"
:key="grandchild.id"
data-context-item
@click="navigateToPath(grandchild.path)"
@contextmenu="handleTreeContextMenu({ event: $event, item: grandchild })"
:class="[
'flex items-center p-1 text-sm rounded-md cursor-pointer transition-all duration-200 group',
'hover:bg-gray-100 dark:hover:bg-gray-700',
currentPath === grandchild.path
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-700 dark:text-gray-300'
]"
>
<div class="w-5"></div>
<Icon
name="mdi:folder"
class="w-4 h-4 mr-1.5 flex-shrink-0 text-blue-600 dark:text-blue-400"
/>
<span class="flex-1 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ grandchild.name }}
</span>
<span
v-if="grandchild.itemCount"
class="text-xs text-gray-500 dark:text-gray-400 ml-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
{{ grandchild.itemCount }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Panel -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Action Toolbar -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<h2 class="text-base font-semibold text-gray-800 dark:text-gray-200">
{{ breadcrumbPath[breadcrumbPath.length - 1]?.name || 'Documents' }}
</h2>
<!-- Sort Options -->
<select
v-model="sortBy"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="name">Sort by Name</option>
<option value="date">Sort by Date</option>
<option value="size">Sort by Size</option>
<option value="type">Sort by Type</option>
</select>
</div>
<div class="flex items-center space-x-1.5">
<!-- View Mode Toggle -->
<div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-600 p-0.5 bg-gray-50 dark:bg-gray-800">
<button
@click="viewMode = 'list'"
:class="[
'px-2 py-1 text-sm font-medium rounded-md transition-all',
viewMode === 'list'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]"
title="List View"
>
<Icon name="mdi:view-list" class="w-4 h-4" />
</button>
<button
@click="viewMode = 'grid'"
:class="[
'px-2 py-1 text-sm font-medium rounded-md transition-all',
viewMode === 'grid'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]"
title="Grid View"
>
<Icon name="mdi:view-grid" class="w-4 h-4" />
</button>
</div>
<!-- Action Buttons -->
<RsButton
variant="secondary-outline"
size="sm"
@click="openUploadModal"
title="Upload files (Ctrl+U)"
class="text-sm font-medium px-3 py-1"
>
<Icon name="mdi:upload" class="w-4 h-4 mr-1.5" />
Upload
</RsButton>
<RsButton
variant="primary"
size="sm"
@click="openCreateNewDialog"
title="Create new item (Ctrl+Shift+N)"
class="text-sm font-medium px-3 py-1"
>
<Icon name="mdi:plus" class="w-4 h-4 mr-1.5" />
Create New
</RsButton>
</div>
</div>
</div>
<!-- Content Area with Preview Panel -->
<div class="flex flex-1 min-h-0">
<!-- File/Folder Content Area -->
<div :class="['flex-1 overflow-auto', showPreviewPanel ? 'border-r border-gray-200 dark:border-gray-700' : '']">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center h-64">
<div class="text-center">
<Icon name="mdi:loading" class="w-8 h-8 animate-spin text-gray-400 mx-auto mb-2" />
<p class="text-gray-500 dark:text-gray-400">Loading...</p>
</div>
</div>
<!-- Empty State -->
<div v-else-if="currentFolderContents.length === 0" class="flex items-center justify-center h-64">
<div class="text-center">
<Icon name="mdi:folder-open" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">This folder is empty</h3>
<p class="text-gray-500 dark:text-gray-400">Drop files here or use the upload button to add content</p>
</div>
</div>
<!-- Content Grid/List -->
<div v-else class="p-2">
<!-- List View -->
<div v-if="viewMode === 'list'" class="space-y-0.5">
<div
v-for="item in currentFolderContents"
:key="item.id"
data-context-item
@click="selectItem(item)"
@dblclick="handleItemDoubleClick(item)"
@contextmenu="handleDocumentContextMenu($event, item)"
@dragstart="handleDragStart($event, item)"
@dragover="handleDragOver($event, item)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, item)"
@mouseenter="showDocumentThumbnail($event, item)"
@mouseleave="hideDocumentThumbnail"
draggable="true"
:class="[
'flex items-center p-2 rounded-lg border transition-all duration-200 cursor-pointer group relative',
selectedItem?.id === item.id
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700'
: selectedItems.has(item.id)
? 'bg-blue-25 dark:bg-blue-900/10 border-blue-200 dark:border-blue-800'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md',
dropTarget?.id === item.id ? 'ring-2 ring-blue-500' : ''
]"
>
<!-- Selection Checkbox -->
<input
type="checkbox"
:checked="selectedItems.has(item.id)"
@change="toggleItemSelection(item)"
@click.stop
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded mr-2"
/>
<!-- Icon -->
<Icon
:name="item.type === 'folder' || item.type === 'drawer' || item.type === 'cabinet'
? 'mdi:folder'
: getFileTypeIcon(item.name)"
:class="[
'w-7 h-7 mr-3 flex-shrink-0',
item.type === 'folder' || item.type === 'drawer' || item.type === 'cabinet'
? 'text-blue-600 dark:text-blue-400'
: getFileTypeColor(item.name)
]"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ item.name }}
</h3>
<div class="flex items-center space-x-3 text-xs text-gray-500 dark:text-gray-400">
<span>{{ formatDate(item.lastModified) }}</span>
<span v-if="item.size">{{ item.size }}</span>
<span v-if="item.itemCount">{{ item.itemCount }} items</span>
</div>
</div>
<p v-if="item.description" class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 truncate">
{{ item.description }}
</p>
<!-- Tags -->
<div v-if="item.tags" class="flex flex-wrap gap-1 mt-1">
<span
v-for="tag in item.tags.slice(0, 3)"
:key="tag"
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium
bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300"
>
{{ tag }}
</span>
<span
v-if="item.tags.length > 3"
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium
bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
>
+{{ item.tags.length - 3 }}
</span>
</div>
</div>
<!-- Quick Actions (visible on hover for files) -->
<div
v-if="item.type === 'file'"
class="absolute right-3 top-1/2 transform -translate-y-1/2 flex items-center space-x-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<button
@click.stop="openDocumentPreview(item, 'view')"
class="p-1 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
title="Preview"
>
<Icon name="mdi:eye" class="w-3 h-3" />
</button>
<button
@click.stop="downloadDocument(item)"
class="p-1 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
title="Download"
>
<Icon name="mdi:download" class="w-3 h-3" />
</button>
<button
v-if="canEdit"
@click.stop="openDocumentPreview(item, 'edit')"
class="p-1 bg-orange-600 hover:bg-orange-700 text-white rounded-md transition-colors"
title="Edit"
>
<Icon name="mdi:pencil" class="w-3 h-3" />
</button>
<button
@click.stop="shareDocument(item)"
class="p-1 bg-purple-600 hover:bg-purple-700 text-white rounded-md transition-colors"
title="Share"
>
<Icon name="mdi:share" class="w-3 h-3" />
</button>
</div>
</div>
</div>
<!-- Grid View -->
<div
v-else
class="grid gap-2"
:style="{ gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))` }"
>
<div
v-for="item in currentFolderContents"
:key="item.id"
data-context-item
@click="selectItem(item)"
@dblclick="handleItemDoubleClick(item)"
@contextmenu="handleDocumentContextMenu($event, item)"
@dragstart="handleDragStart($event, item)"
@dragover="handleDragOver($event, item)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, item)"
draggable="true"
:class="[
'p-3 rounded-lg border transition-all duration-200 cursor-pointer text-center group relative',
selectedItem?.id === item.id
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700'
: selectedItems.has(item.id)
? 'bg-blue-25 dark:bg-blue-900/10 border-blue-200 dark:border-blue-800'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700',
dropTarget?.id === item.id ? 'ring-2 ring-blue-500' : ''
]"
>
<!-- Selection Checkbox -->
<div class="absolute top-1.5 left-1.5">
<input
type="checkbox"
:checked="selectedItems.has(item.id)"
@change="toggleItemSelection(item)"
@click.stop
class="w-3 h-3 text-blue-600 bg-gray-100 border-gray-300 rounded"
/>
</div>
<!-- Icon -->
<Icon
:name="item.type === 'folder' || item.type === 'drawer' || item.type === 'cabinet'
? 'mdi:folder'
: getFileTypeIcon(item.name)"
:class="[
'w-10 h-10 mx-auto mb-1.5',
item.type === 'folder' || item.type === 'drawer' || item.type === 'cabinet'
? 'text-blue-600 dark:text-blue-400'
: getFileTypeColor(item.name)
]"
/>
<!-- Name -->
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ item.name }}
</h3>
<!-- Metadata -->
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<div>{{ formatDate(item.lastModified) }}</div>
<div v-if="item.size">{{ item.size }}</div>
<div v-if="item.itemCount">{{ item.itemCount }} items</div>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Panel -->
<div v-if="showPreviewPanel" class="w-80 bg-white dark:bg-gray-800 flex flex-col">
<div class="border-b border-gray-200 dark:border-gray-700 p-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ selectedItem ? 'Preview' : 'Properties' }}
</h3>
</div>
<div class="flex-1 overflow-auto p-3">
<div v-if="selectedItem">
<!-- File/Folder Info -->
<div class="mb-4">
<div class="flex items-center space-x-2.5 mb-2.5">
<Icon
:name="selectedItem.type === 'folder' || selectedItem.type === 'drawer' || selectedItem.type === 'cabinet'
? 'mdi:folder'
: getFileTypeIcon(selectedItem.name)"
:class="[
'w-7 h-7',
selectedItem.type === 'folder' || selectedItem.type === 'drawer' || selectedItem.type === 'cabinet'
? 'text-blue-600 dark:text-blue-400'
: getFileTypeColor(selectedItem.name)
]"
/>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ selectedItem.name }}
</h4>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ selectedItem.type === 'file' ? 'File' : 'Folder' }}
</p>
</div>
</div>
<!-- Properties -->
<div class="space-y-1.5 text-sm">
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Size:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ selectedItem.size || (selectedItem.itemCount ? `${selectedItem.itemCount} items` : 'Unknown') }}
</span>
</div>
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Modified:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatDate(selectedItem.lastModified) }}
</span>
</div>
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Owner:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ selectedItem.owner || 'Unknown' }}
</span>
</div>
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Access:</span>
<span class="font-medium text-gray-900 dark:text-gray-100 capitalize">
{{ selectedItem.accessLevel || 'Unknown' }}
</span>
</div>
</div>
<!-- Tags -->
<div v-if="selectedItem.tags" class="mt-3">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Tags</label>
<div class="flex flex-wrap gap-1 mt-1.5">
<span
v-for="tag in selectedItem.tags"
:key="tag"
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium
bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400"
>
{{ tag }}
</span>
</div>
</div>
</div>
<!-- Preview Content -->
<div v-if="previewContent" class="mt-4">
<h5 class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Preview
</h5>
<!-- Image Preview -->
<div v-if="previewContent.type === 'image'" class="text-center">
<img
:src="previewContent.url"
:alt="selectedItem.name"
class="max-w-full h-auto rounded-md border border-gray-200 dark:border-gray-600"
/>
</div>
<!-- PDF Preview -->
<div v-else-if="previewContent.type === 'pdf'" class="text-center">
<Icon name="mdi:file-pdf-box" class="w-14 h-14 text-red-600 dark:text-red-400 mx-auto mb-1.5" />
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
PDF Document
</p>
<p class="text-xs font-medium text-gray-500 dark:text-gray-500">
{{ previewContent.metadata?.pages || 'Unknown' }} pages
</p>
</div>
<!-- Text Preview -->
<div v-else-if="previewContent.type === 'text'" class="text-sm">
<div class="bg-gray-50 dark:bg-gray-700 rounded-md p-2.5 border border-gray-200 dark:border-gray-600">
<pre class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap">{{ previewContent.content.substring(0, 200) }}...</pre>
</div>
</div>
<!-- Metadata Preview -->
<div v-else class="text-sm space-y-1.5">
<div v-for="(value, key) in previewContent.metadata" :key="key" class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400 capitalize">{{ key }}:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ value }}</span>
</div>
</div>
</div>
</div>
<!-- No Selection State -->
<div v-else class="text-center py-6">
<Icon name="mdi:information-outline" class="w-10 h-10 text-gray-300 mx-auto mb-1.5" />
<p class="text-gray-500 dark:text-gray-400 text-sm font-medium">
Select a file or folder to view its properties
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Dialogs -->
<UploadWithMetadataModal
v-model:visible="showUploadModal"
:target-folder="{
id: 'current',
name: breadcrumbPath[breadcrumbPath.length - 1]?.name || 'Root',
path: currentPath
}"
:folder-structure="documentStructure"
@upload-complete="handleUploadComplete"
@cancel="showUploadModal = false"
/>
<CreateNewDialog
v-model:visible="showCreateNewDialog"
:initial-path="currentPath"
@create-complete="handleCreateComplete"
@cancel="showCreateNewDialog = false"
/>
<ContextMenu
v-model:visible="showContextMenu"
:x="contextMenuPosition.x"
:y="contextMenuPosition.y"
:target="contextMenuTarget"
@close="showContextMenu = false"
@item-click="handleContextMenuAction"
/>
<!-- Document Preview Dialog -->
<DocumentPreviewDialog
v-model:visible="showDocumentPreview"
:document="previewDocument"
:initial-mode="previewMode"
@close="closeDocumentPreview"
@document-updated="handleDocumentUpdated"
@annotation-added="handleAnnotationAdded"
@annotation-updated="handleAnnotationUpdated"
@annotation-deleted="handleAnnotationDeleted"
@document-shared="handleDocumentShared"
@tag-added="handleTagAdded"
@tag-removed="handleTagRemoved"
/>
<!-- Hover Thumbnail -->
<div
v-if="showHoverThumbnail && hoverThumbnailDocument"
class="fixed z-40 pointer-events-none"
:style="{
left: hoverThumbnailPosition.x + 'px',
top: hoverThumbnailPosition.y + 'px'
}"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-600 p-3 max-w-xs">
<!-- Thumbnail Preview -->
<div class="flex items-center space-x-3 mb-2">
<Icon
:name="getFileTypeIcon(hoverThumbnailDocument.name)"
:class="['w-8 h-8', getFileTypeColor(hoverThumbnailDocument.name)]"
/>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ hoverThumbnailDocument.name }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ hoverThumbnailDocument.size }}
</p>
</div>
</div>
<!-- File Type Specific Preview -->
<div class="bg-gray-100 dark:bg-gray-700 rounded-md p-2 mb-2">
<!-- PDF Preview -->
<div v-if="hoverThumbnailDocument.name?.toLowerCase().endsWith('.pdf')" class="text-center">
<Icon name="mdi:file-pdf-box" class="w-12 h-12 text-red-500 mx-auto mb-1" />
<p class="text-xs text-gray-600 dark:text-gray-400">PDF Document</p>
</div>
<!-- Word Document Preview -->
<div v-else-if="hoverThumbnailDocument.name?.toLowerCase().match(/\.(doc|docx)$/)" class="text-center">
<Icon name="mdi:file-word-box" class="w-12 h-12 text-blue-500 mx-auto mb-1" />
<p class="text-xs text-gray-600 dark:text-gray-400">Word Document</p>
</div>
<!-- Excel Preview -->
<div v-else-if="hoverThumbnailDocument.name?.toLowerCase().match(/\.(xls|xlsx)$/)" class="text-center">
<Icon name="mdi:file-excel-box" class="w-12 h-12 text-green-500 mx-auto mb-1" />
<p class="text-xs text-gray-600 dark:text-gray-400">Excel Spreadsheet</p>
</div>
<!-- Image Preview -->
<div v-else-if="hoverThumbnailDocument.name?.toLowerCase().match(/\.(jpg|jpeg|png|gif)$/)" class="text-center">
<Icon name="mdi:file-image" class="w-12 h-12 text-purple-500 mx-auto mb-1" />
<p class="text-xs text-gray-600 dark:text-gray-400">Image File</p>
</div>
<!-- Generic File Preview -->
<div v-else class="text-center">
<Icon name="mdi:file-document" class="w-12 h-12 text-gray-500 mx-auto mb-1" />
<p class="text-xs text-gray-600 dark:text-gray-400">Document</p>
</div>
</div>
<!-- Document Metadata -->
<div class="space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Modified:</span>
<span class="text-gray-900 dark:text-gray-100">{{ formatDate(hoverThumbnailDocument.lastModified) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Owner:</span>
<span class="text-gray-900 dark:text-gray-100">{{ hoverThumbnailDocument.owner || 'Unknown' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Access:</span>
<span class="text-gray-900 dark:text-gray-100 capitalize">{{ hoverThumbnailDocument.accessLevel }}</span>
</div>
</div>
<!-- Quick Action Hint -->
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-600">
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
Double-click to preview Right-click for options
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Windows Explorer Styling with standardized typography */
.dms-explorer {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #f8fafc;
font-size: 14px;
font-weight: 400;
line-height: 1.5;
height: 100vh;
overflow: hidden;
}
.dark .dms-explorer {
background: #0f172a;
}
/* Typography standardization */
.dms-explorer h1,
.dms-explorer h2,
.dms-explorer h3,
.dms-explorer h4,
.dms-explorer h5,
.dms-explorer h6 {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-weight: 600;
line-height: 1.4;
}
.dms-explorer p,
.dms-explorer span,
.dms-explorer div {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
.dms-explorer button {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-weight: 500;
}
.dms-explorer input,
.dms-explorer select,
.dms-explorer textarea {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
font-weight: 500;
}
/* Grid-based layout */
.dms-explorer * {
box-sizing: border-box;
}
/* Enhanced hover effects */
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
/* File type specific styling */
.file-icon {
transition: transform 0.2s ease-in-out;
}
.file-icon:hover {
transform: scale(1.1);
}
/* Breadcrumb styling */
.breadcrumb-segment {
position: relative;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease-in-out;
}
.breadcrumb-segment:hover {
background-color: rgba(59, 130, 246, 0.1);
}
/* Selection styling */
.item-selected {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3);
}
/* Drag and drop styling */
.drag-over {
background: rgba(59, 130, 246, 0.05);
border: 2px dashed #3b82f6;
transform: scale(1.02);
}
.dragging {
opacity: 0.6;
transform: rotate(5deg);
}
/* List view styling */
.list-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.15s ease-in-out;
}
.list-item:hover {
background-color: rgba(0, 0, 0, 0.03);
transform: translateX(2px);
}
.dark .list-item:hover {
background-color: rgba(255, 255, 255, 0.03);
}
/* Grid view styling */
.grid-item {
aspect-ratio: 1;
padding: 16px;
border-radius: 8px;
text-align: center;
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.grid-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .grid-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Toolbar styling */
.toolbar {
background: linear-gradient(to bottom, #ffffff, #f8fafc);
border-bottom: 1px solid #e2e8f0;
}
.dark .toolbar {
background: linear-gradient(to bottom, #1e293b, #0f172a);
border-bottom: 1px solid #334155;
}
/* Navigation buttons */
.nav-button {
position: relative;
padding: 8px;
border-radius: 6px;
transition: all 0.15s ease-in-out;
background: transparent;
border: none;
cursor: pointer;
}
.nav-button:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.05);
transform: scale(1.05);
}
.nav-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dark .nav-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
}
/* Search box styling */
.search-input {
background: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 8px 12px 8px 40px;
transition: all 0.2s ease-in-out;
font-size: 14px;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
.dark .search-input {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
}
.dark .search-input:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
/* Sidebar styling */
.sidebar {
background: #ffffff;
border-right: 1px solid #e5e7eb;
}
.dark .sidebar {
background: #1f2937;
border-right: 1px solid #374151;
}
/* Tree view styling */
.tree-item {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
.tree-item:hover {
background: rgba(59, 130, 246, 0.05);
}
.tree-item.active {
background: rgba(59, 130, 246, 0.1);
color: #1e40af;
font-weight: 500;
}
.dark .tree-item.active {
color: #93c5fd;
}
/* Preview panel styling */
.preview-panel {
background: #ffffff;
border-left: 1px solid #e5e7eb;
min-height: 0;
}
.dark .preview-panel {
background: #1f2937;
border-left: 1px solid #374151;
}
/* Button styling */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
text-decoration: none;
transition: all 0.15s ease-in-out;
cursor: pointer;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: #ffffff;
border-color: #3b82f6;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}
.btn-secondary {
background: #ffffff;
color: #374151;
border-color: #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.dark .btn-secondary {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.dark .btn-secondary:hover:not(:disabled) {
background: #4b5563;
border-color: #6b7280;
}
/* Custom scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark .custom-scrollbar::-webkit-scrollbar-track {
background: #334155;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Animation classes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInFromLeft {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideInFromRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-in-left {
animation: slideInFromLeft 0.3s ease-out;
}
.animate-slide-in-right {
animation: slideInFromRight 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
/* Context menu styling */
.context-menu {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
padding: 4px;
min-width: 200px;
z-index: 9999;
position: fixed;
}
.dark .context-menu {
background: #374151;
border-color: #4b5563;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease-in-out;
font-size: 14px;
}
.context-menu-item:hover {
background: rgba(59, 130, 246, 0.1);
color: #1e40af;
}
.dark .context-menu-item:hover {
background: rgba(96, 165, 250, 0.1);
color: #93c5fd;
}
.context-menu-separator {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}
.dark .context-menu-separator {
background: #4b5563;
}
/* Loading and empty states */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #6b7280;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 32px;
text-align: center;
color: #6b7280;
}
.dark .loading-state,
.dark .empty-state {
color: #9ca3af;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.dms-explorer {
font-size: 14px;
}
.grid-cols-6 {
grid-template-columns: repeat(3, 1fr);
}
.w-80 {
width: 100%;
position: absolute;
top: 0;
left: 0;
height: 100%;
z-index: 50;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
}
.w-80.open {
transform: translateX(0);
}
}
/* Accessibility */
.focus\:ring-2:focus {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px #3b82f6;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.border-gray-200 {
border-color: #000000;
}
.text-gray-600 {
color: #000000;
}
.bg-gray-50 {
background-color: #ffffff;
}
}
/* Print styles */
@media print {
.dms-explorer {
background: white;
color: black;
}
.sidebar,
.preview-panel {
display: none;
}
.main-content {
width: 100%;
}
}
</style>