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

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# MinIO Configuration
NUXT_MINIO_ACCESS_KEY=your-minio-access-key
NUXT_MINIO_SECRET_KEY=your-minio-secret-key
# NocoDB Configuration (existing)
NUXT_NOCODB_URL=your-nocodb-url
NUXT_NOCODB_TOKEN=your-nocodb-token

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>

132
docs/minio-file-browser.md Normal file
View File

@ -0,0 +1,132 @@
# MinIO File Browser Integration
This document describes the MinIO S3-compatible file browser integration in the Port Nimara Client Portal.
## Features
- **File Management**: Upload, download, delete, and view files
- **Folder Organization**: Create and navigate folder structures
- **File Preview**: View images and PDFs directly in the browser
- **Search**: Quick file search functionality
- **Audit Logging**: Track all file operations with user information
- **Drag & Drop**: Easy file uploads with drag and drop support
- **Size Limit**: 50MB maximum file size per upload
## Configuration
### Environment Variables
Add the following to your `.env` file:
```bash
NUXT_MINIO_ACCESS_KEY=your-minio-access-key
NUXT_MINIO_SECRET_KEY=your-minio-secret-key
```
### MinIO Settings
The MinIO configuration is set in `nuxt.config.ts`:
```javascript
minio: {
endPoint: "s3.portnimara.com",
port: 9000,
useSSL: true,
bucketName: "client-portal",
}
```
## File Structure
```
server/
├── api/
│ └── files/
│ ├── list.ts # List files and folders
│ ├── upload.ts # Upload files
│ ├── download.ts # Generate download URLs
│ ├── delete.ts # Delete files/folders
│ ├── create-folder.ts # Create new folders
│ └── preview.ts # Generate preview URLs
├── utils/
│ └── minio.ts # MinIO client utilities
pages/
└── dashboard/
└── file-browser.vue # Main file browser page
components/
├── FileUploader.vue # File upload component
└── FilePreviewModal.vue # File preview modal
```
## API Endpoints
### List Files
- **GET** `/api/files/list`
- Query params: `prefix` (folder path), `recursive` (boolean)
### Upload Files
- **POST** `/api/files/upload`
- Body: FormData with files
- Query params: `path` (current folder)
### Download File
- **GET** `/api/files/download`
- Query params: `fileName`
### Delete File/Folder
- **POST** `/api/files/delete`
- Body: `{ fileName, isFolder }`
### Create Folder
- **POST** `/api/files/create-folder`
- Body: `{ folderPath }`
### Preview File
- **GET** `/api/files/preview`
- Query params: `fileName`
## Usage
1. Navigate to "File Browser" from the dashboard menu
2. Use the interface to:
- Upload files by dragging or clicking "Upload Files"
- Create folders with "New Folder" button
- Navigate folders by clicking on them
- Preview images and PDFs by clicking the eye icon
- Download files with the download icon
- Delete files/folders with the delete icon
## Audit Logging
All file operations are logged with:
- User email
- Action type (upload, download, delete, create_folder)
- File path
- Timestamp
- IP address
- Success status
Currently logs to console, but can be easily integrated with your database.
## Security
- All operations require authentication
- File names are sanitized to prevent security issues
- Presigned URLs expire after 1 hour
- 50MB file size limit enforced
## Supported File Types
All file types are supported for upload/download. Preview is available for:
- Images: JPG, JPEG, PNG, GIF, SVG, WebP
- Documents: PDF
## Future Enhancements
- Database storage for audit logs
- File sharing with expiration
- Bulk operations
- File versioning
- Integration with Interest management (link files to specific interests)

View File

@ -22,6 +22,14 @@ export default defineNuxtConfig({
url: "",
token: "",
},
minio: {
endPoint: "s3.portnimara.com",
port: 9000,
useSSL: true,
accessKey: "279QFJV96Ja9wNB0YYmU1W3Pv4Ofeh3pxojcz0pzeC5LjRurq",
secretKey: "y8ze6nmA2VHJWDsIU1eNEBq4R4WlmJWp97UE0zUR7E4zWLS6O",
bucketName: "client-portal",
},
public: {
directus: {
url: "https://cms.portnimara.dev",

366
package-lock.json generated
View File

@ -7,6 +7,9 @@
"hasInstallScript": true,
"dependencies": {
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"mime-types": "^3.0.1",
"minio": "^8.0.5",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"v-phone-input": "^4.4.2",
@ -2366,6 +2369,18 @@
"node": ">=18.0.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2758,6 +2773,15 @@
"vue": "^3.3.4"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
"integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@ -4111,6 +4135,13 @@
"vuetify": "^3.0.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/abbrev": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz",
@ -4407,6 +4438,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/ast-kit": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-1.4.0.tgz",
@ -4646,6 +4683,29 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/block-stream2": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
"integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/block-stream2/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@ -4674,6 +4734,12 @@
"node": ">=8"
}
},
"node_modules/browser-or-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
@ -5593,6 +5659,15 @@
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -5731,6 +5806,16 @@
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"license": "MIT"
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
@ -6166,6 +6251,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -6275,6 +6366,24 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
@ -6346,6 +6455,15 @@
"node": ">=8"
}
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
@ -6402,6 +6520,23 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -7607,6 +7742,15 @@
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@ -7616,6 +7760,22 @@
"url": "https://github.com/sponsors/brc-dd"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -8613,6 +8773,27 @@
"node": ">=16"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
@ -8637,6 +8818,52 @@
"node": "*"
}
},
"node_modules/minio": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.5.tgz",
"integrity": "sha512-/vAze1uyrK2R/DSkVutE4cjVoAowvIQ18RAwn7HrqnLecLlMazFnY0oNBqfuoAWvu7mZIGX75AzpuV05TJeoHg==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.4",
"block-stream2": "^2.1.0",
"browser-or-node": "^2.1.1",
"buffer-crc32": "^1.0.0",
"eventemitter3": "^5.0.1",
"fast-xml-parser": "^4.4.1",
"ipaddr.js": "^2.0.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"query-string": "^7.1.3",
"stream-json": "^1.8.0",
"through2": "^4.0.2",
"web-encoding": "^1.1.5",
"xml2js": "^0.5.0 || ^0.6.2"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/minio/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minio/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
@ -10179,6 +10406,24 @@
"node": ">=6"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -10762,6 +11007,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
@ -11142,6 +11393,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@ -11163,6 +11423,21 @@
"integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==",
"license": "MIT"
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/streamx": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
@ -11176,6 +11451,15 @@
"bare-events": "^2.2.0"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -11375,6 +11659,18 @@
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"license": "MIT"
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/stylehacks": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz",
@ -11609,6 +11905,29 @@
"b4a": "^1.6.4"
}
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
"license": "MIT",
"dependencies": {
"readable-stream": "3"
}
},
"node_modules/through2/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@ -12823,6 +13142,19 @@
"integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==",
"license": "MIT"
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -13389,6 +13721,18 @@
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"license": "MIT"
},
"node_modules/web-encoding": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz",
"integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==",
"license": "MIT",
"dependencies": {
"util": "^0.12.3"
},
"optionalDependencies": {
"@zxing/text-encoding": "0.9.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -13952,6 +14296,28 @@
}
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -9,6 +9,9 @@
},
"dependencies": {
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"mime-types": "^3.0.1",
"minio": "^8.0.5",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"v-phone-input": "^4.4.2",

View File

@ -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(() =>

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

View File

@ -0,0 +1,59 @@
import { createFolder } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { folderPath } = body;
if (!folderPath) {
throw createError({
statusCode: 400,
statusMessage: 'Folder path is required',
});
}
// Create the folder
await createFolder(folderPath);
// Log audit event
await logAuditEvent(event, 'create_folder', folderPath);
return {
success: true,
message: 'Folder created successfully',
folderPath,
};
} catch (error: any) {
console.error('Failed to create folder:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to create folder',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

View File

@ -0,0 +1,62 @@
import { deleteFile, deleteFolder } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { fileName, isFolder } = body;
if (!fileName) {
throw createError({
statusCode: 400,
statusMessage: 'File name is required',
});
}
// Delete folder or file based on type
if (isFolder) {
await deleteFolder(fileName);
} else {
await deleteFile(fileName);
}
// Log audit event
await logAuditEvent(event, 'delete', fileName);
return {
success: true,
message: isFolder ? 'Folder deleted successfully' : 'File deleted successfully',
};
} catch (error: any) {
console.error('Failed to delete:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to delete',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

View File

@ -0,0 +1,59 @@
import { getDownloadUrl } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const fileName = query.fileName as string;
if (!fileName) {
throw createError({
statusCode: 400,
statusMessage: 'File name is required',
});
}
// Generate presigned URL valid for 1 hour
const url = await getDownloadUrl(fileName);
// Log audit event
await logAuditEvent(event, 'download', fileName);
return {
success: true,
url,
fileName,
};
} catch (error: any) {
console.error('Failed to generate download URL:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to generate download URL',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

86
server/api/files/list.ts Normal file
View File

@ -0,0 +1,86 @@
import { listFiles } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const prefix = (query.prefix as string) || '';
const recursive = query.recursive === 'true';
const files = await listFiles(prefix, recursive);
// Format file list with additional metadata
const formattedFiles = (files as any[]).map(file => ({
...file,
sizeFormatted: file.isFolder ? '-' : formatFileSize(file.size),
extension: file.isFolder ? 'folder' : getFileExtension(file.name),
icon: file.isFolder ? 'mdi-folder' : getFileIcon(file.name),
displayName: getDisplayName(file.name),
}));
// Sort folders first, then files
formattedFiles.sort((a, b) => {
if (a.isFolder && !b.isFolder) return -1;
if (!a.isFolder && b.isFolder) return 1;
return a.displayName.localeCompare(b.displayName);
});
return {
success: true,
files: formattedFiles,
count: formattedFiles.length,
currentPath: prefix,
};
} catch (error) {
console.error('Failed to list files:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to list files',
});
}
});
// Helper functions
function 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];
}
function getFileExtension(filename: string): string {
const parts = filename.split('.');
return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : '';
}
function getFileIcon(filename: string): string {
const ext = getFileExtension(filename);
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';
}
function getDisplayName(filepath: string): string {
// Get just the filename from the full path
const parts = filepath.split('/');
const filename = parts[parts.length - 1];
// Remove timestamp prefix if present (e.g., "1234567890-filename.pdf" -> "filename.pdf")
const match = filename.match(/^\d{10,}-(.+)$/);
return match ? match[1] : filename;
}

View File

@ -0,0 +1,53 @@
import { getPreviewUrl } from '~/server/utils/minio';
import mime from 'mime-types';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const fileName = query.fileName as string;
if (!fileName) {
throw createError({
statusCode: 400,
statusMessage: 'File name is required',
});
}
// Get content type
const contentType = mime.lookup(fileName) || 'application/octet-stream';
// Check if file type supports preview
const supportedPreviewTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/svg+xml',
'image/webp',
'application/pdf',
];
if (!supportedPreviewTypes.includes(contentType)) {
throw createError({
statusCode: 400,
statusMessage: 'File type does not support preview',
});
}
// Generate presigned URL for preview
const url = await getPreviewUrl(fileName, contentType);
return {
success: true,
url,
fileName,
contentType,
};
} catch (error: any) {
console.error('Failed to generate preview URL:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to generate preview URL',
});
}
});

View File

@ -0,0 +1,97 @@
import { uploadFile } from '~/server/utils/minio';
import formidable from 'formidable';
import { promises as fs } from 'fs';
import mime from 'mime-types';
export default defineEventHandler(async (event) => {
try {
// Get the current path from query params
const query = getQuery(event);
const currentPath = (query.path as string) || '';
// Parse multipart form data
const form = formidable({
maxFileSize: 50 * 1024 * 1024, // 50MB limit
keepExtensions: true,
});
const [fields, files] = await form.parse(event.node.req);
// Handle multiple files
const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file];
const results = [];
for (const uploadedFile of uploadedFiles) {
if (!uploadedFile) continue;
// Read file buffer
const fileBuffer = await fs.readFile(uploadedFile.filepath);
// Generate unique filename to prevent collisions
const timestamp = Date.now();
const sanitizedName = uploadedFile.originalFilename?.replace(/[^a-zA-Z0-9.-]/g, '_') || 'file';
const fileName = `${timestamp}-${sanitizedName}`;
// Construct full path including current folder
const fullPath = currentPath ? `${currentPath}${fileName}` : fileName;
// Get content type
const contentType = mime.lookup(uploadedFile.originalFilename || '') || 'application/octet-stream';
// Upload to MinIO
await uploadFile(fullPath, fileBuffer, contentType);
// Clean up temp file
await fs.unlink(uploadedFile.filepath);
results.push({
fileName: fullPath,
originalName: uploadedFile.originalFilename,
size: uploadedFile.size,
contentType,
});
// Log audit event
await logAuditEvent(event, 'upload', fullPath, uploadedFile.size);
}
return {
success: true,
files: results,
message: `${results.length} file(s) uploaded successfully`,
};
} catch (error: any) {
console.error('Failed to upload file:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to upload file',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string, fileSize?: number) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
file_size: fileSize,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

167
server/utils/minio.ts Normal file
View File

@ -0,0 +1,167 @@
import { Client } from 'minio';
import type { BucketItem } from 'minio';
// Initialize MinIO client
export const getMinioClient = () => {
const config = useRuntimeConfig().minio;
return new Client({
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
});
};
// File listing with metadata
export const listFiles = async (prefix: string = '', recursive: boolean = false) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
const files: any[] = [];
const folders = new Set<string>();
return new Promise((resolve, reject) => {
const stream = client.listObjectsV2(bucketName, prefix, recursive);
stream.on('data', (obj) => {
if (!recursive && prefix) {
// Extract folder structure when not recursive
const relativePath = obj.name.substring(prefix.length);
const firstSlash = relativePath.indexOf('/');
if (firstSlash > -1) {
// This is a folder
const folderName = relativePath.substring(0, firstSlash);
folders.add(prefix + folderName + '/');
} else if (relativePath) {
// This is a file in the current folder
files.push({
name: obj.name,
size: obj.size,
lastModified: obj.lastModified,
etag: obj.etag,
isFolder: false,
});
}
} else {
// When recursive or at root, include all files
if (!obj.name.endsWith('/')) {
files.push({
name: obj.name,
size: obj.size,
lastModified: obj.lastModified,
etag: obj.etag,
isFolder: false,
});
}
}
});
stream.on('error', reject);
stream.on('end', () => {
// Add folders to the result
const folderItems = Array.from(folders).map(folder => ({
name: folder,
size: 0,
lastModified: new Date(),
etag: '',
isFolder: true,
}));
resolve([...folderItems, ...files]);
});
});
};
// Upload file
export const uploadFile = async (filePath: string, fileBuffer: Buffer, contentType: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.putObject(bucketName, filePath, fileBuffer, fileBuffer.length, {
'Content-Type': contentType,
});
};
// Generate presigned URL for download
export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.presignedGetObject(bucketName, fileName, expiry);
};
// Delete file
export const deleteFile = async (fileName: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.removeObject(bucketName, fileName);
};
// Delete folder (recursively delete all contents)
export const deleteFolder = async (folderPath: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// List all objects in the folder
const objectsList: string[] = [];
return new Promise((resolve, reject) => {
const stream = client.listObjectsV2(bucketName, folderPath, true);
stream.on('data', (obj) => {
objectsList.push(obj.name);
});
stream.on('error', reject);
stream.on('end', async () => {
try {
// Delete all objects
if (objectsList.length > 0) {
await client.removeObjects(bucketName, objectsList);
}
resolve(true);
} catch (error) {
reject(error);
}
});
});
};
// Get file stats
export const getFileStats = async (fileName: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.statObject(bucketName, fileName);
};
// Create folder (MinIO doesn't have explicit folders, so we create a placeholder)
export const createFolder = async (folderPath: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// Ensure folder path ends with /
const normalizedPath = folderPath.endsWith('/') ? folderPath : folderPath + '/';
// Create an empty object to represent the folder
return await client.putObject(bucketName, normalizedPath, Buffer.from(''), 0);
};
// Get presigned URL for file preview
export const getPreviewUrl = async (fileName: string, contentType: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// For images and PDFs, generate a presigned URL with appropriate response headers
const responseHeaders = {
'response-content-type': contentType,
'response-content-disposition': 'inline',
};
return await client.presignedGetObject(bucketName, fileName, 60 * 60, responseHeaders);
};