Add proxy downloads and bulk file operations
- Implement proxy download endpoint for better mobile Safari compatibility - Add bulk selection with checkboxes in file browser - Add bulk actions bar for downloading/deleting selected files - Replace direct S3 downloads with server-proxied downloads - Fix download issues on mobile devices by using proper link handling
This commit is contained in:
parent
1463fdb3d7
commit
b7544d82f3
|
|
@ -204,14 +204,12 @@ const downloadFile = async () => {
|
|||
if (!props.file) return;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/download', {
|
||||
params: { fileName: props.file.name },
|
||||
});
|
||||
// Use proxy download endpoint for better mobile compatibility
|
||||
const proxyUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(props.file.name)}`;
|
||||
|
||||
// For mobile Safari, we need to use a different approach
|
||||
// Create a link element
|
||||
const link = document.createElement('a');
|
||||
link.href = response.url;
|
||||
link.target = '_blank';
|
||||
link.href = proxyUrl;
|
||||
|
||||
// Extract clean filename for download
|
||||
let filename = props.file.displayName;
|
||||
|
|
@ -220,9 +218,14 @@ const downloadFile = async () => {
|
|||
}
|
||||
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link);
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,14 +63,52 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Bulk Actions Bar (shown when items selected) -->
|
||||
<v-row v-if="selectedItems.length > 0" class="mb-4">
|
||||
<v-col>
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
closable
|
||||
@click:close="selectedItems = []"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>{{ selectedItems.length }} item(s) selected</span>
|
||||
<div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="downloadSelected"
|
||||
prepend-icon="mdi-download"
|
||||
class="mr-2"
|
||||
>
|
||||
Download
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="confirmDeleteSelected"
|
||||
prepend-icon="mdi-delete"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- File List -->
|
||||
<v-card>
|
||||
<v-data-table
|
||||
v-model="selectedItems"
|
||||
:headers="headers"
|
||||
:items="filteredFiles"
|
||||
:loading="loading"
|
||||
:items-per-page="25"
|
||||
class="elevation-0"
|
||||
show-select
|
||||
item-value="name"
|
||||
>
|
||||
<template v-slot:item.displayName="{ item }">
|
||||
<div
|
||||
|
|
@ -295,6 +333,7 @@ const toast = useToast();
|
|||
// Data
|
||||
const files = ref<FileItem[]>([]);
|
||||
const filteredFiles = ref<FileItem[]>([]);
|
||||
const selectedItems = ref<string[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
|
|
@ -460,19 +499,17 @@ const createNewFolder = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Download file
|
||||
// Download file (using proxy for Safari compatibility)
|
||||
const downloadFile = async (file: FileItem) => {
|
||||
downloadingFiles.value[file.name] = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/download', {
|
||||
params: { fileName: file.name },
|
||||
});
|
||||
// Use proxy download endpoint for better mobile compatibility
|
||||
const proxyUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(file.name)}`;
|
||||
|
||||
// For mobile Safari compatibility, use a link element
|
||||
// Create a link element
|
||||
const link = document.createElement('a');
|
||||
link.href = response.url;
|
||||
link.target = '_blank';
|
||||
link.href = proxyUrl;
|
||||
|
||||
// Extract clean filename for download
|
||||
let filename = file.displayName;
|
||||
|
|
@ -481,16 +518,94 @@ const downloadFile = async (file: FileItem) => {
|
|||
}
|
||||
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate download link');
|
||||
toast.error('Failed to download file');
|
||||
} finally {
|
||||
downloadingFiles.value[file.name] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Download selected files
|
||||
const downloadSelected = async () => {
|
||||
const selectedFiles = files.value.filter(file => selectedItems.value.includes(file.name) && !file.isFolder);
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
toast.warning('Please select files to download (folders cannot be downloaded)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Download files sequentially with a small delay
|
||||
for (const file of selectedFiles) {
|
||||
await downloadFile(file);
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // Small delay between downloads
|
||||
}
|
||||
|
||||
selectedItems.value = [];
|
||||
};
|
||||
|
||||
// Confirm delete selected
|
||||
const confirmDeleteSelected = () => {
|
||||
const itemsToDelete = files.value.filter(file => selectedItems.value.includes(file.name));
|
||||
|
||||
if (itemsToDelete.length === 0) return;
|
||||
|
||||
const hasFolder = itemsToDelete.some(item => item.isFolder);
|
||||
const message = hasFolder
|
||||
? `Are you sure you want to delete ${itemsToDelete.length} item(s)? This includes folders with all their contents.`
|
||||
: `Are you sure you want to delete ${itemsToDelete.length} file(s)?`;
|
||||
|
||||
if (confirm(message)) {
|
||||
deleteSelected();
|
||||
}
|
||||
};
|
||||
|
||||
// Delete selected items
|
||||
const deleteSelected = async () => {
|
||||
const itemsToDelete = files.value.filter(file => selectedItems.value.includes(file.name));
|
||||
|
||||
deleting.value = true;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
try {
|
||||
for (const item of itemsToDelete) {
|
||||
try {
|
||||
await $fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: item.name,
|
||||
isFolder: item.isFolder,
|
||||
},
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && errorCount === 0) {
|
||||
toast.success(`${successCount} item(s) deleted successfully`);
|
||||
} else if (successCount > 0 && errorCount > 0) {
|
||||
toast.warning(`${successCount} item(s) deleted, ${errorCount} failed`);
|
||||
} else {
|
||||
toast.error('Failed to delete items');
|
||||
}
|
||||
|
||||
selectedItems.value = [];
|
||||
await loadFiles();
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = (file: FileItem) => {
|
||||
fileToDelete.value = file;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
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',
|
||||
});
|
||||
}
|
||||
|
||||
// Get the download URL from MinIO
|
||||
const url = await getDownloadUrl(fileName);
|
||||
|
||||
// Fetch the file from MinIO
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Failed to fetch file from storage',
|
||||
});
|
||||
}
|
||||
|
||||
// Get the file data
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Extract clean filename (remove timestamp prefix)
|
||||
let cleanFileName = fileName.split('/').pop() || fileName;
|
||||
const timestampMatch = cleanFileName.match(/^\d{10,}-(.+)$/);
|
||||
if (timestampMatch) {
|
||||
cleanFileName = timestampMatch[1];
|
||||
}
|
||||
|
||||
// Get content type
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
|
||||
// Set headers for download
|
||||
setHeader(event, 'Content-Type', contentType);
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${cleanFileName}"`);
|
||||
// Content-Length header is set automatically by Nitro when returning a buffer
|
||||
|
||||
// Return the file buffer
|
||||
return buffer;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to proxy download:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to download file',
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue