Add MinIO file browser with upload, preview, and management features
- Implement file browser UI with upload/download capabilities - Add API endpoints for file operations (list, upload, delete, preview) - Create FileUploader and FilePreviewModal components - Configure MinIO integration with environment variables - Add documentation for MinIO file browser setup
This commit is contained in:
220
components/FilePreviewModal.vue
Normal file
220
components/FilePreviewModal.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="isOpen"
|
||||
max-width="90vw"
|
||||
max-height="90vh"
|
||||
scrollable
|
||||
>
|
||||
<v-card v-if="file">
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-2">{{ file.icon }}</v-icon>
|
||||
<span>{{ file.displayName }}</span>
|
||||
</div>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="closeModal"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center pa-10">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
/>
|
||||
<p class="mt-4">Loading preview...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center pa-10">
|
||||
<v-icon size="64" color="error">mdi-alert-circle</v-icon>
|
||||
<p class="mt-4">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<div v-else-if="isImage" class="image-preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
:alt="file.displayName"
|
||||
class="image-preview"
|
||||
@load="loading = false"
|
||||
@error="handlePreviewError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PDF Preview -->
|
||||
<div v-else-if="isPdf" class="pdf-preview-container">
|
||||
<iframe
|
||||
:src="previewUrl"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
@load="loading = false"
|
||||
@error="handlePreviewError"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="downloadFile"
|
||||
prepend-icon="mdi-download"
|
||||
>
|
||||
Download
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="closeModal"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
lastModified: string;
|
||||
extension: string;
|
||||
icon: string;
|
||||
displayName: string;
|
||||
isFolder: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
file: FileItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const previewUrl = ref('');
|
||||
|
||||
// Computed property for v-model binding
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// Check if file is an image
|
||||
const isImage = computed(() => {
|
||||
if (!props.file) return false;
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
|
||||
return imageExtensions.includes(props.file.extension.toLowerCase());
|
||||
});
|
||||
|
||||
// Check if file is a PDF
|
||||
const isPdf = computed(() => {
|
||||
if (!props.file) return false;
|
||||
return props.file.extension.toLowerCase() === 'pdf';
|
||||
});
|
||||
|
||||
// Watch for file changes and load preview
|
||||
watch(() => props.file, async (newFile) => {
|
||||
if (newFile && props.modelValue) {
|
||||
await loadPreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for dialog open and load preview
|
||||
watch(() => props.modelValue, async (isOpen) => {
|
||||
if (isOpen && props.file) {
|
||||
await loadPreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Load preview URL
|
||||
const loadPreview = async () => {
|
||||
if (!props.file) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
previewUrl.value = '';
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/preview', {
|
||||
params: { fileName: props.file.name },
|
||||
});
|
||||
|
||||
previewUrl.value = response.url;
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.statusMessage || 'Failed to load preview';
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle preview load error
|
||||
const handlePreviewError = () => {
|
||||
error.value = 'Failed to load preview';
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// Download file
|
||||
const downloadFile = async () => {
|
||||
if (!props.file) return;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/download', {
|
||||
params: { fileName: props.file.name },
|
||||
});
|
||||
|
||||
window.open(response.url, '_blank');
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal
|
||||
const closeModal = () => {
|
||||
isOpen.value = false;
|
||||
previewUrl.value = '';
|
||||
error.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-preview-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pdf-preview-container {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
208
components/FileUploader.vue
Normal file
208
components/FileUploader.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone pa-8 text-center rounded-lg"
|
||||
:class="{ 'drop-zone-active': isDragging }"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
>
|
||||
<v-icon size="64" color="primary" class="mb-4">
|
||||
mdi-cloud-upload-outline
|
||||
</v-icon>
|
||||
<h3 class="text-h6 mb-2">Drag and drop files here</h3>
|
||||
<p class="text-body-2 text-grey mb-4">or</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="openFileDialog"
|
||||
prepend-icon="mdi-folder-open"
|
||||
>
|
||||
Browse Files
|
||||
</v-btn>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<p class="text-caption text-grey mt-4">
|
||||
Maximum file size: 50MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Selected Files -->
|
||||
<v-list v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<v-list-subheader>Selected Files ({{ selectedFiles.length }})</v-list-subheader>
|
||||
<v-list-item
|
||||
v-for="(file, index) in selectedFiles"
|
||||
:key="index"
|
||||
:title="file.name"
|
||||
:subtitle="formatFileSize(file.size)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ getFileIcon(file.name) }}</v-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<v-progress-linear
|
||||
v-if="uploading && uploadProgress > 0"
|
||||
:model-value="uploadProgress"
|
||||
color="primary"
|
||||
height="8"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
<v-card-actions v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<v-spacer />
|
||||
<v-btn @click="clearFiles">Clear All</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="uploadFiles"
|
||||
:loading="uploading"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
>
|
||||
Upload {{ selectedFiles.length }} File{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
uploading: boolean;
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'upload', files: File[]): void;
|
||||
(e: 'close'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const isDragging = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
addFiles(files);
|
||||
|
||||
// Reset input value to allow selecting same file again
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
// Add files to selection with validation
|
||||
const addFiles = (files: File[]) => {
|
||||
const validFiles = files.filter(file => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(`File "${file.name}" exceeds 50MB limit`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
selectedFiles.value = [...selectedFiles.value, ...validFiles];
|
||||
};
|
||||
|
||||
// Remove file from selection
|
||||
const removeFile = (index: number) => {
|
||||
selectedFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// Clear all files
|
||||
const clearFiles = () => {
|
||||
selectedFiles.value = [];
|
||||
uploadProgress.value = 0;
|
||||
};
|
||||
|
||||
// Upload files
|
||||
const uploadFiles = () => {
|
||||
if (selectedFiles.value.length === 0) return;
|
||||
emit('upload', selectedFiles.value);
|
||||
};
|
||||
|
||||
// Open file dialog
|
||||
const openFileDialog = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getFileIcon = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const iconMap: Record<string, string> = {
|
||||
pdf: 'mdi-file-pdf-box',
|
||||
doc: 'mdi-file-document',
|
||||
docx: 'mdi-file-document',
|
||||
xls: 'mdi-file-excel',
|
||||
xlsx: 'mdi-file-excel',
|
||||
jpg: 'mdi-file-image',
|
||||
jpeg: 'mdi-file-image',
|
||||
png: 'mdi-file-image',
|
||||
gif: 'mdi-file-image',
|
||||
svg: 'mdi-file-image',
|
||||
zip: 'mdi-folder-zip',
|
||||
rar: 'mdi-folder-zip',
|
||||
txt: 'mdi-file-document-outline',
|
||||
csv: 'mdi-file-delimited',
|
||||
mp4: 'mdi-file-video',
|
||||
mp3: 'mdi-file-music',
|
||||
};
|
||||
return iconMap[ext] || 'mdi-file';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drop-zone {
|
||||
border: 2px dashed #ccc;
|
||||
transition: all 0.3s;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.drop-zone-active {
|
||||
border-color: #1976d2;
|
||||
background-color: rgba(25, 118, 210, 0.05);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user