EDMS/pages/dms/index.vue
shb 6cb4396f20 Adding mapping function to match API data with current document structure
The current API endpoint data doesn't match the document structure in the frontend logic. So I introduced a mapping function but it has not been called into the code yet as this needs a major refactoring to do.
2025-06-13 11:29:45 +08:00

2727 lines
87 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;
};
const mapAPIToDocumentStructure = async () => {
const response = await fetch('/api/dms/folder');
const APIData = await response.json();
// const documentStructure.value = APIData.map(obj => {
// id: obj.cb_id,
// name: obj.cb_name,
// type: obj.cb_type || "folder",
// parent_id: obj.cb_parent_id,
// access_level: obj.cb_access_level || "public",
// itemCount: null,
// children: obj.children_count || null
// })
return documentStructure.value;
}
// Navigation functions
const buildBreadcrumbs = (path) => {
if (path === '/') {
return [{ name: 'Root', path: '/', type: 'root' }];
}
const crumbs = [{ name: 'Root', path: '/', type: 'root' }];
const segments = path.split('/').filter(Boolean);
// const documentStructure.value = await mapAPIToDocumentStructure();
let itemsToSearch = documentStructure.value;
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);
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(() => {
checkMobileView();
// Add event listeners
window.addEventListener('resize', updateGridLayout);
window.addEventListener('keydown', handleKeyboardShortcuts);
document.addEventListener('click', handleGlobalClick);
document.addEventListener('contextmenu', handleGlobalContextMenu);
// 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>