EDMS/components/dms/dialogs/UploadWithMetadataModal.vue
shb 40cf8ebab5 Added file upload functionality
Backend works when trying to use Postman to request the API endpoint. File upload in the frontend also works since the data is parsed properly as multi-part form data. The issue is the frontend seems to cannot directly send request to backend and is outright rejected.
2025-06-16 14:29:33 +08:00

1116 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",
// Let browser automatically set headers for multipart/form-data
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>