generated from corrad-software/corrad-af-2024

Direct-to-storage upload instead of sending to the backend since sending files to the backend from the frontend is forbidden for some reason.
1115 lines
42 KiB
Vue
1115 lines
42 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
|
import { useNotifications } from '~/composables/useNotifications';
|
|
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
targetFolder: {
|
|
type: Object,
|
|
default: () => ({ id: null, name: 'Root', path: '/' })
|
|
},
|
|
allowedFileTypes: {
|
|
type: Array,
|
|
default: () => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'png']
|
|
},
|
|
maxFileSize: {
|
|
type: Number,
|
|
default: 100 // MB
|
|
},
|
|
folderStructure: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:visible', 'upload-complete', 'cancel']);
|
|
|
|
// Notifications
|
|
const { success, error, warning } = useNotifications();
|
|
|
|
// Upload state
|
|
const selectedFiles = ref([]);
|
|
const isDragOver = ref(false);
|
|
const isUploading = ref(false);
|
|
const uploadProgress = ref(0);
|
|
const currentStep = ref(1); // 1: File Selection, 2: Location Selection, 3: Metadata, 4: Permissions
|
|
|
|
// Location selection state
|
|
const selectedLocation = ref(props.targetFolder);
|
|
const expandedFolders = ref(new Set(['/']));
|
|
const showLocationPicker = ref(false);
|
|
|
|
// Metadata state
|
|
const availableTags = ref([
|
|
'Financial', 'Legal', 'HR', 'IT', 'Project', 'Annual', 'Monthly', 'Draft', 'Final',
|
|
'Important', 'Urgent', 'Confidential', 'Public', 'Internal', 'Budget', 'Report'
|
|
]);
|
|
|
|
const departments = ref([
|
|
'Finance', 'Human Resources', 'Information Technology', 'Legal', 'Operations',
|
|
'Marketing', 'Sales', 'Engineering', 'Administration'
|
|
]);
|
|
|
|
const confidentialityLevels = ref([
|
|
{ value: 'public', label: 'Public', description: 'Accessible to everyone' },
|
|
{ value: 'internal', label: 'Internal', description: 'Company employees only' },
|
|
{ value: 'confidential', label: 'Confidential', description: 'Restricted access' },
|
|
{ value: 'secret', label: 'Secret', description: 'Highly restricted' }
|
|
]);
|
|
|
|
// File metadata
|
|
const fileMetadata = ref([]);
|
|
const newTagInput = ref('');
|
|
const tagSuggestions = ref([]);
|
|
const showTagSuggestions = ref(false);
|
|
|
|
// Permission settings
|
|
const accessPermissions = ref({
|
|
inheritFromParent: true,
|
|
level: 'internal',
|
|
specificUsers: [],
|
|
specificGroups: []
|
|
});
|
|
|
|
// Computed
|
|
const canProceedToLocation = computed(() => {
|
|
return selectedFiles.value.length > 0;
|
|
});
|
|
|
|
const canProceedToMetadata = computed(() => {
|
|
return selectedLocation.value && selectedLocation.value.path;
|
|
});
|
|
|
|
const canProceedToPermissions = computed(() => {
|
|
return fileMetadata.value.length > 0 && fileMetadata.value.every(file =>
|
|
file.department &&
|
|
file.department.trim() !== '' &&
|
|
file.confidentiality &&
|
|
file.confidentiality.trim() !== '' &&
|
|
file.tags &&
|
|
file.tags.length > 0
|
|
);
|
|
});
|
|
|
|
const canUpload = computed(() => {
|
|
return canProceedToPermissions.value && (
|
|
accessPermissions.value.inheritFromParent || accessPermissions.value.level
|
|
);
|
|
});
|
|
|
|
// Folder structure handling
|
|
const buildFolderTree = (folders) => {
|
|
const tree = [];
|
|
const folderMap = new Map();
|
|
|
|
// Add root
|
|
const rootFolder = { id: 'root', name: 'Root', path: '/', children: [], type: 'root' };
|
|
tree.push(rootFolder);
|
|
folderMap.set('/', rootFolder);
|
|
|
|
// Process folder structure
|
|
folders.forEach(folder => {
|
|
folderMap.set(folder.path, { ...folder, children: [] });
|
|
});
|
|
|
|
// Build hierarchy
|
|
folders.forEach(folder => {
|
|
const pathSegments = folder.path.split('/').filter(Boolean);
|
|
const parentPath = pathSegments.length > 1 ? '/' + pathSegments.slice(0, -1).join('/') : '/';
|
|
const parent = folderMap.get(parentPath);
|
|
|
|
if (parent) {
|
|
parent.children.push(folderMap.get(folder.path));
|
|
} else {
|
|
tree.push(folderMap.get(folder.path));
|
|
}
|
|
});
|
|
|
|
return tree;
|
|
};
|
|
|
|
const folderTree = computed(() => {
|
|
return buildFolderTree(props.folderStructure);
|
|
});
|
|
|
|
const isExpandedFolder = (path) => {
|
|
return expandedFolders.value.has(path);
|
|
};
|
|
|
|
const toggleFolderExpansion = (folder) => {
|
|
if (expandedFolders.value.has(folder.path)) {
|
|
expandedFolders.value.delete(folder.path);
|
|
} else {
|
|
expandedFolders.value.add(folder.path);
|
|
}
|
|
};
|
|
|
|
// File handling
|
|
const handleFileSelect = (event) => {
|
|
const files = Array.from(event.target.files);
|
|
addFiles(files);
|
|
};
|
|
|
|
const handleDrop = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragOver.value = false;
|
|
|
|
const files = Array.from(event.dataTransfer.files);
|
|
addFiles(files);
|
|
};
|
|
|
|
const handleDragOver = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragOver.value = true;
|
|
};
|
|
|
|
const handleDragLeave = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragOver.value = false;
|
|
};
|
|
|
|
const handleDragEnter = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragOver.value = true;
|
|
};
|
|
|
|
const addFiles = (files) => {
|
|
const validFiles = files.filter(file => {
|
|
const extension = file.name.split('.').pop().toLowerCase();
|
|
const sizeMB = file.size / (1024 * 1024);
|
|
|
|
if (!props.allowedFileTypes.includes(extension)) {
|
|
error(`File type .${extension} is not allowed`);
|
|
return false;
|
|
}
|
|
|
|
if (sizeMB > props.maxFileSize) {
|
|
error(`File ${file.name} exceeds ${props.maxFileSize}MB limit`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
selectedFiles.value = [...selectedFiles.value, ...validFiles];
|
|
|
|
// Initialize metadata for new files
|
|
validFiles.forEach(file => {
|
|
fileMetadata.value.push({
|
|
file,
|
|
name: file.name,
|
|
department: '',
|
|
confidentiality: 'internal',
|
|
category: '',
|
|
tags: [],
|
|
description: ''
|
|
});
|
|
});
|
|
|
|
if (validFiles.length > 0 && currentStep.value === 1) {
|
|
nextStep();
|
|
}
|
|
};
|
|
|
|
// Location selection
|
|
const selectLocation = (folder) => {
|
|
selectedLocation.value = folder;
|
|
};
|
|
|
|
const openFileBrowser = () => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.multiple = true;
|
|
input.accept = props.allowedFileTypes.map(ext => `.${ext}`).join(',');
|
|
input.onchange = (event) => {
|
|
const files = Array.from(event.target.files);
|
|
addFiles(files);
|
|
};
|
|
input.click();
|
|
};
|
|
|
|
const removeFile = (index) => {
|
|
selectedFiles.value.splice(index, 1);
|
|
fileMetadata.value.splice(index, 1);
|
|
|
|
if (selectedFiles.value.length === 0) {
|
|
currentStep.value = 1;
|
|
}
|
|
};
|
|
|
|
// Metadata handling
|
|
const currentTagInput = ref('');
|
|
const currentFileIndex = ref(null);
|
|
|
|
const handleTagInput = (fileIndex, value) => {
|
|
currentTagInput.value = value;
|
|
currentFileIndex.value = fileIndex;
|
|
|
|
if (value.length > 0) {
|
|
const suggestions = availableTags.value.filter(tag =>
|
|
tag.toLowerCase().includes(value.toLowerCase()) &&
|
|
!fileMetadata.value[fileIndex].tags.includes(tag)
|
|
);
|
|
tagSuggestions.value = suggestions;
|
|
showTagSuggestions.value = suggestions.length > 0;
|
|
} else {
|
|
showTagSuggestions.value = false;
|
|
}
|
|
};
|
|
|
|
const addTag = (fileIndex, tag) => {
|
|
if (!fileMetadata.value[fileIndex].tags.includes(tag)) {
|
|
fileMetadata.value[fileIndex].tags.push(tag);
|
|
}
|
|
currentTagInput.value = '';
|
|
showTagSuggestions.value = false;
|
|
};
|
|
|
|
const removeTag = (fileIndex, tagIndex) => {
|
|
fileMetadata.value[fileIndex].tags.splice(tagIndex, 1);
|
|
};
|
|
|
|
const createNewTag = (fileIndex, tagValue = null) => {
|
|
const tag = (tagValue || currentTagInput.value).trim();
|
|
if (tag && !fileMetadata.value[fileIndex].tags.includes(tag)) {
|
|
// Add to available tags for future use
|
|
if (!availableTags.value.includes(tag)) {
|
|
availableTags.value.push(tag);
|
|
}
|
|
addTag(fileIndex, tag);
|
|
}
|
|
};
|
|
|
|
const handleTagKeydown = (event, fileIndex) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
if (currentTagInput.value.trim()) {
|
|
createNewTag(fileIndex);
|
|
}
|
|
} else if (event.key === 'Escape') {
|
|
currentTagInput.value = '';
|
|
showTagSuggestions.value = false;
|
|
}
|
|
};
|
|
|
|
// Navigation
|
|
const nextStep = () => {
|
|
if (currentStep.value < 4) {
|
|
currentStep.value++;
|
|
}
|
|
};
|
|
|
|
const previousStep = () => {
|
|
if (currentStep.value > 1) {
|
|
currentStep.value--;
|
|
}
|
|
};
|
|
|
|
// Upload process
|
|
const performUpload = async () => {
|
|
try {
|
|
isUploading.value = true;
|
|
uploadProgress.value = 0;
|
|
|
|
for (let i = 0; i < selectedFiles.value.length; i++) {
|
|
const file = selectedFiles.value[i];
|
|
const metadata = fileMetadata.value[i];
|
|
|
|
// Simulate upload progress
|
|
for (let progress = 0; progress <= 100; progress += 10) {
|
|
uploadProgress.value = Math.round(((i * 100) + progress) / selectedFiles.value.length);
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
|
|
console.log(file);
|
|
|
|
// Here you would implement actual file upload
|
|
|
|
const formData = new FormData();
|
|
formData.append('fileName', metadata.name);
|
|
// formData.append('file', file);
|
|
|
|
console.log(formData);
|
|
|
|
const response = await fetch('/api/dms/upload-file', {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
console.log(response);
|
|
// console.log('Uploading:', file.name, 'with metadata:', metadata);
|
|
}
|
|
|
|
success(`Successfully uploaded ${selectedFiles.value.length} file(s)`);
|
|
emit('upload-complete', {
|
|
files: selectedFiles.value,
|
|
metadata: fileMetadata.value,
|
|
permissions: accessPermissions.value,
|
|
targetFolder: selectedLocation.value
|
|
});
|
|
|
|
closeModal();
|
|
} catch (err) {
|
|
error('Failed to upload files: ' + err.message);
|
|
} finally {
|
|
isUploading.value = false;
|
|
}
|
|
};
|
|
|
|
// Modal controls
|
|
const closeModal = () => {
|
|
selectedFiles.value = [];
|
|
fileMetadata.value = [];
|
|
currentStep.value = 1;
|
|
uploadProgress.value = 0;
|
|
currentTagInput.value = '';
|
|
currentFileIndex.value = null;
|
|
showTagSuggestions.value = false;
|
|
selectedLocation.value = props.targetFolder;
|
|
emit('update:visible', false);
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (isUploading.value) {
|
|
// Ask for confirmation if upload is in progress
|
|
if (confirm('Upload is in progress. Are you sure you want to cancel?')) {
|
|
closeModal();
|
|
emit('cancel');
|
|
}
|
|
} else {
|
|
closeModal();
|
|
emit('cancel');
|
|
}
|
|
};
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes) => {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
};
|
|
|
|
// Get file icon
|
|
const getFileIcon = (fileName) => {
|
|
const extension = fileName.split('.').pop().toLowerCase();
|
|
const iconMap = {
|
|
pdf: 'mdi:file-pdf',
|
|
doc: 'mdi:file-word',
|
|
docx: 'mdi:file-word',
|
|
xls: 'mdi:file-excel',
|
|
xlsx: 'mdi:file-excel',
|
|
ppt: 'mdi:file-powerpoint',
|
|
pptx: 'mdi:file-powerpoint',
|
|
txt: 'mdi:file-document',
|
|
jpg: 'mdi:file-image',
|
|
png: 'mdi:file-image',
|
|
default: 'mdi:file'
|
|
};
|
|
return iconMap[extension] || iconMap.default;
|
|
};
|
|
|
|
// Keyboard shortcuts and click handlers
|
|
const handleKeydown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
if (showTagSuggestions.value) {
|
|
showTagSuggestions.value = false;
|
|
currentTagInput.value = '';
|
|
} else {
|
|
handleCancel();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleGlobalClick = (event) => {
|
|
// Close tag suggestions when clicking outside
|
|
if (showTagSuggestions.value && !event.target.closest('.tag-input-container')) {
|
|
showTagSuggestions.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleKeydown);
|
|
document.addEventListener('click', handleGlobalClick);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeydown);
|
|
document.removeEventListener('click', handleGlobalClick);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="visible"
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
@click.self="handleCancel"
|
|
>
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
|
|
|
|
<!-- Modal -->
|
|
<div class="relative min-h-screen flex items-center justify-center p-4">
|
|
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center space-x-3">
|
|
<Icon name="mdi:upload" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
Upload Documents
|
|
</h2>
|
|
</div>
|
|
<button
|
|
@click="handleCancel"
|
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<Icon name="mdi:close" class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Progress Steps -->
|
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div
|
|
v-for="step in 4"
|
|
:key="step"
|
|
class="flex items-center"
|
|
:class="step < 4 ? 'flex-1' : ''"
|
|
>
|
|
<div
|
|
:class="[
|
|
'flex items-center justify-center w-8 h-8 rounded-full border-2 text-sm font-medium',
|
|
currentStep >= step
|
|
? 'bg-blue-600 border-blue-600 text-white'
|
|
: 'bg-white border-gray-300 text-gray-500'
|
|
]"
|
|
>
|
|
{{ step }}
|
|
</div>
|
|
<span
|
|
:class="[
|
|
'ml-2 text-sm font-medium',
|
|
currentStep >= step ? 'text-blue-600' : 'text-gray-500'
|
|
]"
|
|
>
|
|
{{
|
|
step === 1 ? 'Select Files' :
|
|
step === 2 ? 'Choose Location' :
|
|
step === 3 ? 'Add Metadata' :
|
|
'Set Permissions'
|
|
}}
|
|
</span>
|
|
<div
|
|
v-if="step < 4"
|
|
:class="[
|
|
'flex-1 h-0.5 ml-4',
|
|
currentStep > step ? 'bg-blue-600' : 'bg-gray-300'
|
|
]"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="px-6 py-4 max-h-[60vh] overflow-y-auto">
|
|
<!-- Step 1: File Selection -->
|
|
<div v-if="currentStep === 1" class="space-y-4">
|
|
<div class="text-center">
|
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
|
Uploading to: <span class="font-medium">{{ targetFolder.name }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Drag & Drop Area -->
|
|
<div
|
|
@drop="handleDrop"
|
|
@dragover="handleDragOver"
|
|
@dragenter="handleDragEnter"
|
|
@dragleave="handleDragLeave"
|
|
@click="openFileBrowser"
|
|
:class="[
|
|
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
|
|
isDragOver
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/10'
|
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
|
]"
|
|
>
|
|
<Icon
|
|
name="mdi:cloud-upload"
|
|
:class="[
|
|
'w-12 h-12 mx-auto mb-4',
|
|
isDragOver ? 'text-blue-500' : 'text-gray-400'
|
|
]"
|
|
/>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
Drop files here or click to browse
|
|
</h3>
|
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
|
Supported formats: {{ allowedFileTypes.join(', ').toUpperCase() }}
|
|
</p>
|
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
|
Maximum file size: {{ maxFileSize }}MB
|
|
</p>
|
|
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
multiple
|
|
:accept="allowedFileTypes.map(ext => '.' + ext).join(',')"
|
|
@change="handleFileSelect"
|
|
class="hidden"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Selected Files Preview -->
|
|
<div v-if="selectedFiles.length > 0" class="space-y-2">
|
|
<h4 class="font-medium text-gray-900 dark:text-gray-100">
|
|
Selected Files ({{ selectedFiles.length }})
|
|
</h4>
|
|
<div class="max-h-32 overflow-y-auto space-y-2">
|
|
<div
|
|
v-for="(file, index) in selectedFiles"
|
|
:key="index"
|
|
class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-md"
|
|
>
|
|
<div class="flex items-center space-x-3">
|
|
<Icon :name="getFileIcon(file.name)" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{{ file.name }}
|
|
</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ formatFileSize(file.size) }}
|
|
</span>
|
|
</div>
|
|
<button
|
|
@click="removeFile(index)"
|
|
class="text-red-500 hover:text-red-700"
|
|
>
|
|
<Icon name="mdi:close" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Location Selection -->
|
|
<div v-if="currentStep === 2" class="space-y-4">
|
|
<div class="text-center mb-6">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
Choose Upload Location
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-400">
|
|
Select where you want to upload your {{ selectedFiles.length }} file(s)
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Current Selection Display -->
|
|
<div class="bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
|
|
<div class="flex items-center space-x-3">
|
|
<Icon name="mdi:folder" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
|
<div>
|
|
<p class="font-medium text-gray-900 dark:text-gray-100">
|
|
{{ selectedLocation.name }}
|
|
</p>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ selectedLocation.path }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Folder Tree -->
|
|
<div class="border border-gray-200 dark:border-gray-600 rounded-lg max-h-80 overflow-y-auto">
|
|
<div class="p-3">
|
|
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
Available Locations
|
|
</h4>
|
|
|
|
<!-- Folder Tree Structure -->
|
|
<div class="space-y-1">
|
|
<div
|
|
v-for="folder in folderTree"
|
|
:key="folder.id"
|
|
class="folder-tree-item"
|
|
>
|
|
<!-- Root or Cabinet Level -->
|
|
<div
|
|
@click="selectLocation(folder)"
|
|
:class="[
|
|
'flex items-center p-2 rounded-md cursor-pointer transition-colors',
|
|
selectedLocation.path === folder.path
|
|
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
]"
|
|
>
|
|
<!-- Expand/Collapse Button -->
|
|
<button
|
|
v-if="folder.children && folder.children.length > 0"
|
|
@click.stop="toggleFolderExpansion(folder)"
|
|
class="flex-shrink-0 w-4 h-4 mr-2 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
<Icon
|
|
:name="isExpandedFolder(folder.path) ? 'mdi:chevron-down' : 'mdi:chevron-right'"
|
|
class="w-4 h-4"
|
|
/>
|
|
</button>
|
|
<div v-else class="w-6"></div>
|
|
|
|
<!-- Folder Icon -->
|
|
<Icon
|
|
:name="folder.type === 'root' ? 'mdi:home' : 'mdi:folder'"
|
|
class="w-4 h-4 mr-2 text-blue-600 dark:text-blue-400"
|
|
/>
|
|
|
|
<!-- Folder Name -->
|
|
<span class="flex-1">{{ folder.name }}</span>
|
|
|
|
<!-- Access Level Badge -->
|
|
<span
|
|
v-if="folder.accessLevel"
|
|
:class="[
|
|
'text-xs px-2 py-1 rounded-full',
|
|
folder.accessLevel === 'public' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' :
|
|
folder.accessLevel === 'private' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' :
|
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
|
|
]"
|
|
>
|
|
{{ folder.accessLevel }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Children (Expanded) -->
|
|
<div
|
|
v-if="folder.children && isExpandedFolder(folder.path)"
|
|
class="ml-6 mt-1 space-y-1"
|
|
>
|
|
<div
|
|
v-for="child in folder.children"
|
|
:key="child.id"
|
|
class="folder-tree-item"
|
|
>
|
|
<div
|
|
@click="selectLocation(child)"
|
|
:class="[
|
|
'flex items-center p-2 rounded-md cursor-pointer transition-colors',
|
|
selectedLocation.path === child.path
|
|
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
]"
|
|
>
|
|
<!-- Expand/Collapse for child -->
|
|
<button
|
|
v-if="child.children && child.children.length > 0"
|
|
@click.stop="toggleFolderExpansion(child)"
|
|
class="flex-shrink-0 w-4 h-4 mr-2 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
<Icon
|
|
:name="isExpandedFolder(child.path) ? 'mdi:chevron-down' : 'mdi:chevron-right'"
|
|
class="w-4 h-4"
|
|
/>
|
|
</button>
|
|
<div v-else class="w-6"></div>
|
|
|
|
<Icon name="mdi:folder" class="w-4 h-4 mr-2 text-blue-600 dark:text-blue-400" />
|
|
<span class="flex-1">{{ child.name }}</span>
|
|
|
|
<span
|
|
v-if="child.accessLevel"
|
|
:class="[
|
|
'text-xs px-2 py-1 rounded-full',
|
|
child.accessLevel === 'public' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' :
|
|
child.accessLevel === 'private' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' :
|
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
|
|
]"
|
|
>
|
|
{{ child.accessLevel }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Grandchildren -->
|
|
<div
|
|
v-if="child.children && isExpandedFolder(child.path)"
|
|
class="ml-6 mt-1 space-y-1"
|
|
>
|
|
<div
|
|
v-for="grandchild in child.children"
|
|
:key="grandchild.id"
|
|
@click="selectLocation(grandchild)"
|
|
:class="[
|
|
'flex items-center p-2 rounded-md cursor-pointer transition-colors',
|
|
selectedLocation.path === grandchild.path
|
|
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
]"
|
|
>
|
|
<div class="w-6"></div>
|
|
<Icon name="mdi:folder" class="w-4 h-4 mr-2 text-blue-600 dark:text-blue-400" />
|
|
<span class="flex-1">{{ grandchild.name }}</span>
|
|
|
|
<span
|
|
v-if="grandchild.accessLevel"
|
|
:class="[
|
|
'text-xs px-2 py-1 rounded-full',
|
|
grandchild.accessLevel === 'public' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' :
|
|
grandchild.accessLevel === 'private' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' :
|
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
|
|
]"
|
|
>
|
|
{{ grandchild.accessLevel }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Metadata -->
|
|
<div v-if="currentStep === 3" class="space-y-6">
|
|
<div
|
|
v-for="(metadata, fileIndex) in fileMetadata"
|
|
:key="fileIndex"
|
|
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
|
|
>
|
|
<div class="flex items-center space-x-3 mb-4">
|
|
<Icon :name="getFileIcon(metadata.file.name)" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
|
<h4 class="font-medium text-gray-900 dark:text-gray-100">
|
|
{{ metadata.file.name }}
|
|
</h4>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<!-- Department -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Department *
|
|
</label>
|
|
<select
|
|
v-model="metadata.department"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
required
|
|
>
|
|
<option value="">Select Department</option>
|
|
<option v-for="dept in departments" :key="dept" :value="dept">
|
|
{{ dept }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Confidentiality -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Confidentiality Level *
|
|
</label>
|
|
<select
|
|
v-model="metadata.confidentiality"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
required
|
|
>
|
|
<option
|
|
v-for="level in confidentialityLevels"
|
|
:key="level.value"
|
|
:value="level.value"
|
|
>
|
|
{{ level.label }} - {{ level.description }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Category -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Category
|
|
</label>
|
|
<input
|
|
v-model="metadata.category"
|
|
type="text"
|
|
placeholder="e.g., Report, Proposal, Invoice"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Tags -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Tags *
|
|
</label>
|
|
<div class="relative tag-input-container">
|
|
<input
|
|
v-model="currentTagInput"
|
|
@input="handleTagInput(fileIndex, $event.target.value)"
|
|
@keydown="handleTagKeydown($event, fileIndex)"
|
|
type="text"
|
|
placeholder="Type to search or add new tags..."
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
|
|
<!-- Tag Suggestions -->
|
|
<div
|
|
v-if="showTagSuggestions && tagSuggestions.length > 0"
|
|
class="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800
|
|
border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-10 max-h-32 overflow-y-auto"
|
|
>
|
|
<button
|
|
v-for="suggestion in tagSuggestions"
|
|
:key="suggestion"
|
|
@click="addTag(fileIndex, suggestion)"
|
|
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700
|
|
flex items-center justify-between"
|
|
>
|
|
<span>{{ suggestion }}</span>
|
|
<Icon name="mdi:plus" class="w-4 h-4 text-gray-400" />
|
|
</button>
|
|
|
|
<!-- Create New Tag Option -->
|
|
<div
|
|
v-if="currentTagInput.trim() && !tagSuggestions.includes(currentTagInput.trim())"
|
|
class="border-t border-gray-200 dark:border-gray-600"
|
|
>
|
|
<button
|
|
@click="createNewTag(fileIndex, currentTagInput.trim())"
|
|
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700
|
|
flex items-center justify-between text-blue-600 dark:text-blue-400"
|
|
>
|
|
<span>Create "{{ currentTagInput.trim() }}"</span>
|
|
<Icon name="mdi:plus-circle" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create New Tag if no suggestions -->
|
|
<div
|
|
v-if="currentTagInput.trim() && !showTagSuggestions && currentTagInput.length > 0"
|
|
class="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800
|
|
border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-10"
|
|
>
|
|
<button
|
|
@click="createNewTag(fileIndex, currentTagInput.trim())"
|
|
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700
|
|
flex items-center justify-between text-blue-600 dark:text-blue-400"
|
|
>
|
|
<span>Create "{{ currentTagInput.trim() }}"</span>
|
|
<Icon name="mdi:plus-circle" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Helper Text -->
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
Type to search existing tags or create new ones. Press Enter to add.
|
|
</p>
|
|
|
|
<!-- Selected Tags -->
|
|
<div v-if="metadata.tags && metadata.tags.length > 0" class="flex flex-wrap gap-2 mt-2">
|
|
<span
|
|
v-for="(tag, tagIndex) in metadata.tags"
|
|
:key="tagIndex"
|
|
class="inline-flex items-center px-2 py-1 bg-blue-100 dark:bg-blue-900/20
|
|
text-blue-800 dark:text-blue-400 text-xs font-medium rounded-md
|
|
hover:bg-blue-200 dark:hover:bg-blue-900/30 transition-colors"
|
|
>
|
|
{{ tag }}
|
|
<button
|
|
@click="removeTag(fileIndex, tagIndex)"
|
|
class="ml-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300
|
|
hover:bg-blue-300 dark:hover:bg-blue-800 rounded-full p-0.5 transition-colors"
|
|
title="Remove tag"
|
|
>
|
|
<Icon name="mdi:close" class="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- No Tags Message -->
|
|
<div v-if="!metadata.tags || metadata.tags.length === 0" class="mt-2">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
|
No tags added yet. Start typing to add tags.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="mt-4">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
v-model="metadata.description"
|
|
rows="2"
|
|
placeholder="Brief description of the document..."
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4: Permissions -->
|
|
<div v-if="currentStep === 4" class="space-y-6">
|
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-4">
|
|
Access Permissions
|
|
</h4>
|
|
|
|
<!-- Inherit from Parent -->
|
|
<div class="mb-4">
|
|
<label class="flex items-center">
|
|
<input
|
|
v-model="accessPermissions.inheritFromParent"
|
|
type="checkbox"
|
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded
|
|
focus:ring-blue-500 dark:focus:ring-blue-600 dark:bg-gray-700 dark:border-gray-600"
|
|
/>
|
|
<span class="ml-2 text-sm text-gray-900 dark:text-gray-100">
|
|
Inherit permissions from parent folder ({{ selectedLocation.name }})
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Custom Permissions -->
|
|
<div v-if="!accessPermissions.inheritFromParent" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Access Level
|
|
</label>
|
|
<select
|
|
v-model="accessPermissions.level"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option
|
|
v-for="level in confidentialityLevels"
|
|
:key="level.value"
|
|
:value="level.value"
|
|
>
|
|
{{ level.label }} - {{ level.description }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Progress -->
|
|
<div v-if="isUploading" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="flex-1">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading files...</span>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ uploadProgress }}%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
:style="{ width: uploadProgress + '%' }"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
{{ selectedFiles.length }} file(s) selected
|
|
</div>
|
|
|
|
<div class="flex items-center space-x-3">
|
|
<RsButton
|
|
v-if="currentStep > 1"
|
|
variant="secondary-outline"
|
|
@click="previousStep"
|
|
:disabled="isUploading"
|
|
>
|
|
Previous
|
|
</RsButton>
|
|
|
|
<RsButton
|
|
variant="secondary-outline"
|
|
@click="handleCancel"
|
|
:disabled="isUploading"
|
|
>
|
|
Cancel
|
|
</RsButton>
|
|
|
|
<RsButton
|
|
v-if="currentStep < 4"
|
|
variant="primary"
|
|
@click="nextStep"
|
|
:disabled="
|
|
currentStep === 1 ? !canProceedToLocation :
|
|
currentStep === 2 ? !canProceedToMetadata :
|
|
currentStep === 3 ? !canProceedToPermissions : false
|
|
"
|
|
>
|
|
Next
|
|
</RsButton>
|
|
|
|
<RsButton
|
|
v-if="currentStep === 4"
|
|
variant="primary"
|
|
@click="performUpload"
|
|
:disabled="!canUpload || isUploading"
|
|
>
|
|
<Icon v-if="isUploading" name="mdi:loading" class="w-4 h-4 mr-2 animate-spin" />
|
|
{{ isUploading ? 'Uploading...' : 'Upload Files' }}
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Custom styling for Windows Explorer feel */
|
|
.upload-modal {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
}
|
|
|
|
/* File drag and drop styling */
|
|
.drag-over {
|
|
border-color: #3b82f6;
|
|
background-color: #eff6ff;
|
|
}
|
|
|
|
/* Smooth transitions */
|
|
.transition-all {
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
|
|
/* Progress bar animation */
|
|
@keyframes progress {
|
|
0% { width: 0%; }
|
|
100% { width: 100%; }
|
|
}
|
|
|
|
/* Tag input focus */
|
|
.tag-input:focus {
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
|
}
|
|
</style> |