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:
2025-06-04 16:32:50 +02:00
parent 42efcf3ce1
commit 61cefa530e
16 changed files with 2017 additions and 0 deletions

View 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
View 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>