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:
@@ -117,6 +117,11 @@ const defaultMenu = [
|
||||
icon: "mdi-finance",
|
||||
title: "Data Analytics",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/file-browser",
|
||||
icon: "mdi-folder",
|
||||
title: "File Browser",
|
||||
},
|
||||
];
|
||||
|
||||
const menu = computed(() =>
|
||||
|
||||
485
pages/dashboard/file-browser.vue
Normal file
485
pages/dashboard/file-browser.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- Header -->
|
||||
<v-row class="mb-6">
|
||||
<v-col>
|
||||
<h1 class="text-h4 font-weight-bold">
|
||||
<v-icon class="mr-2" color="primary">mdi-folder</v-icon>
|
||||
File Browser
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-grey mt-1">
|
||||
Manage your NDA documents and other files
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<v-row class="mb-4" v-if="currentPath">
|
||||
<v-col>
|
||||
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:to="item.to"
|
||||
@click="navigateToFolder(item.path)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Search files..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
@update:model-value="filterFiles"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="d-flex justify-end ga-2">
|
||||
<v-btn
|
||||
color="secondary"
|
||||
size="large"
|
||||
@click="newFolderDialog = true"
|
||||
prepend-icon="mdi-folder-plus"
|
||||
variant="outlined"
|
||||
>
|
||||
New Folder
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
@click="uploadDialog = true"
|
||||
prepend-icon="mdi-upload"
|
||||
>
|
||||
Upload Files
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- File List -->
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredFiles"
|
||||
:loading="loading"
|
||||
:items-per-page="25"
|
||||
class="elevation-0"
|
||||
>
|
||||
<template v-slot:item.displayName="{ item }">
|
||||
<div
|
||||
class="d-flex align-center py-2 cursor-pointer"
|
||||
@click="handleFileClick(item)"
|
||||
>
|
||||
<v-icon :icon="item.icon" class="mr-3" :color="item.isFolder ? 'primary' : ''" />
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ item.displayName }}</div>
|
||||
<div class="text-caption text-grey" v-if="!item.isFolder">
|
||||
{{ item.extension.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.sizeFormatted="{ item }">
|
||||
<span class="text-body-2">{{ item.sizeFormatted }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.lastModified="{ item }">
|
||||
<span class="text-body-2">{{ formatDate(item.lastModified) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="d-flex justify-end">
|
||||
<v-btn
|
||||
v-if="canPreview(item)"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click.stop="previewFile(item)"
|
||||
>
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Preview</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!item.isFolder"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click.stop="downloadFile(item)"
|
||||
:loading="downloadingFiles[item.name]"
|
||||
>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Download</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click.stop="confirmDelete(item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<v-empty-state
|
||||
icon="mdi-folder-open-outline"
|
||||
title="No files found"
|
||||
:text="currentPath ? 'This folder is empty' : 'Upload your first file to get started'"
|
||||
class="my-6"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<v-dialog v-model="uploadDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-upload</v-icon>
|
||||
Upload Files
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<FileUploader
|
||||
@upload="handleFileUpload"
|
||||
@close="uploadDialog = false"
|
||||
:uploading="uploading"
|
||||
:current-path="currentPath"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<v-dialog v-model="newFolderDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-folder-plus</v-icon>
|
||||
Create New Folder
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newFolderName"
|
||||
label="Folder Name"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
autofocus
|
||||
@keyup.enter="createNewFolder"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="newFolderDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="createNewFolder"
|
||||
:loading="creatingFolder"
|
||||
:disabled="!newFolderName"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
|
||||
Confirm Delete
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete "{{ fileToDelete?.displayName }}"?
|
||||
<span v-if="fileToDelete?.isFolder">
|
||||
This will delete all files and folders inside it.
|
||||
</span>
|
||||
This action cannot be undone.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
@click="deleteFile"
|
||||
:loading="deleting"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- File Preview Dialog -->
|
||||
<FilePreviewModal
|
||||
v-model="previewDialog"
|
||||
:file="fileToPreview"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import FileUploader from '~/components/FileUploader.vue';
|
||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
lastModified: string;
|
||||
extension: string;
|
||||
icon: string;
|
||||
displayName: string;
|
||||
isFolder: boolean;
|
||||
}
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// Data
|
||||
const files = ref<FileItem[]>([]);
|
||||
const filteredFiles = ref<FileItem[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const creatingFolder = ref(false);
|
||||
const uploadDialog = ref(false);
|
||||
const deleteDialog = ref(false);
|
||||
const newFolderDialog = ref(false);
|
||||
const previewDialog = ref(false);
|
||||
const fileToDelete = ref<FileItem | null>(null);
|
||||
const fileToPreview = ref<FileItem | null>(null);
|
||||
const downloadingFiles = ref<Record<string, boolean>>({});
|
||||
const currentPath = ref('');
|
||||
const newFolderName = ref('');
|
||||
|
||||
// Table headers
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'displayName', sortable: true },
|
||||
{ title: 'Size', key: 'sizeFormatted', sortable: true },
|
||||
{ title: 'Modified', key: 'lastModified', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' as const },
|
||||
];
|
||||
|
||||
// Breadcrumb items
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [
|
||||
{ title: 'Root', path: '', to: '#' }
|
||||
];
|
||||
|
||||
if (currentPath.value) {
|
||||
const parts = currentPath.value.split('/').filter(Boolean);
|
||||
let accumulated = '';
|
||||
|
||||
parts.forEach(part => {
|
||||
accumulated += part + '/';
|
||||
items.push({
|
||||
title: part,
|
||||
path: accumulated,
|
||||
to: '#'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Load files
|
||||
const loadFiles = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await $fetch('/api/files/list', {
|
||||
params: {
|
||||
prefix: currentPath.value,
|
||||
recursive: false,
|
||||
}
|
||||
});
|
||||
files.value = response.files;
|
||||
filteredFiles.value = response.files;
|
||||
} catch (error) {
|
||||
toast.error('Failed to load files');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter files based on search
|
||||
const filterFiles = () => {
|
||||
if (!searchQuery.value) {
|
||||
filteredFiles.value = files.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
filteredFiles.value = files.value.filter(file =>
|
||||
file.displayName.toLowerCase().includes(query)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle file/folder click
|
||||
const handleFileClick = (item: FileItem) => {
|
||||
if (item.isFolder) {
|
||||
navigateToFolder(item.name);
|
||||
} else if (canPreview(item)) {
|
||||
previewFile(item);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to folder
|
||||
const navigateToFolder = (folderPath: string) => {
|
||||
currentPath.value = folderPath;
|
||||
searchQuery.value = '';
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
// Check if file can be previewed
|
||||
const canPreview = (file: FileItem) => {
|
||||
if (file.isFolder) return false;
|
||||
const previewableExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'pdf'];
|
||||
return previewableExtensions.includes(file.extension);
|
||||
};
|
||||
|
||||
// Preview file
|
||||
const previewFile = (file: FileItem) => {
|
||||
fileToPreview.value = file;
|
||||
previewDialog.value = true;
|
||||
};
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = async (uploadedFiles: File[]) => {
|
||||
uploading.value = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
uploadedFiles.forEach(file => {
|
||||
formData.append('file', file);
|
||||
});
|
||||
|
||||
await $fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
params: {
|
||||
path: currentPath.value,
|
||||
}
|
||||
});
|
||||
|
||||
toast.success(`${uploadedFiles.length} file(s) uploaded successfully`);
|
||||
uploadDialog.value = false;
|
||||
await loadFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload files');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new folder
|
||||
const createNewFolder = async () => {
|
||||
if (!newFolderName.value) return;
|
||||
|
||||
creatingFolder.value = true;
|
||||
try {
|
||||
const folderPath = currentPath.value
|
||||
? `${currentPath.value}${newFolderName.value}/`
|
||||
: `${newFolderName.value}/`;
|
||||
|
||||
await $fetch('/api/files/create-folder', {
|
||||
method: 'POST',
|
||||
body: { folderPath },
|
||||
});
|
||||
|
||||
toast.success('Folder created successfully');
|
||||
newFolderDialog.value = false;
|
||||
newFolderName.value = '';
|
||||
await loadFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to create folder');
|
||||
} finally {
|
||||
creatingFolder.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Download file
|
||||
const downloadFile = async (file: FileItem) => {
|
||||
downloadingFiles.value[file.name] = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/download', {
|
||||
params: { fileName: file.name },
|
||||
});
|
||||
|
||||
// Open download URL in new tab
|
||||
window.open(response.url, '_blank');
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate download link');
|
||||
} finally {
|
||||
downloadingFiles.value[file.name] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = (file: FileItem) => {
|
||||
fileToDelete.value = file;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
|
||||
// Delete file
|
||||
const deleteFile = async () => {
|
||||
if (!fileToDelete.value) return;
|
||||
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: fileToDelete.value.name,
|
||||
isFolder: fileToDelete.value.isFolder,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(fileToDelete.value.isFolder ? 'Folder deleted successfully' : 'File deleted successfully');
|
||||
deleteDialog.value = false;
|
||||
await loadFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete');
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
fileToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Load files on mount
|
||||
onMounted(() => {
|
||||
loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user