EDMS/components/dms/dialogs/UploadWithMetadataModal.vue
shb cb7f104b1d Removed sending file to the backend
Direct-to-storage upload instead of sending to the backend since sending files to the backend from the frontend is forbidden for some reason.
2025-06-18 12:13:06 +08:00

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>