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;
|
if (!props.file) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/files/download', {
|
// Use proxy download endpoint for better mobile compatibility
|
||||||
params: { fileName: props.file.name },
|
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');
|
const link = document.createElement('a');
|
||||||
link.href = response.url;
|
link.href = proxyUrl;
|
||||||
link.target = '_blank';
|
|
||||||
|
|
||||||
// Extract clean filename for download
|
// Extract clean filename for download
|
||||||
let filename = props.file.displayName;
|
let filename = props.file.displayName;
|
||||||
|
|
@ -220,9 +218,14 @@ const downloadFile = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
|
link.style.display = 'none';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
setTimeout(() => {
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to download file:', err);
|
console.error('Failed to download file:', err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,52 @@
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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 -->
|
<!-- File List -->
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-data-table
|
<v-data-table
|
||||||
|
v-model="selectedItems"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items="filteredFiles"
|
:items="filteredFiles"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:items-per-page="25"
|
:items-per-page="25"
|
||||||
class="elevation-0"
|
class="elevation-0"
|
||||||
|
show-select
|
||||||
|
item-value="name"
|
||||||
>
|
>
|
||||||
<template v-slot:item.displayName="{ item }">
|
<template v-slot:item.displayName="{ item }">
|
||||||
<div
|
<div
|
||||||
|
|
@ -295,6 +333,7 @@ const toast = useToast();
|
||||||
// Data
|
// Data
|
||||||
const files = ref<FileItem[]>([]);
|
const files = ref<FileItem[]>([]);
|
||||||
const filteredFiles = ref<FileItem[]>([]);
|
const filteredFiles = ref<FileItem[]>([]);
|
||||||
|
const selectedItems = ref<string[]>([]);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const uploading = 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) => {
|
const downloadFile = async (file: FileItem) => {
|
||||||
downloadingFiles.value[file.name] = true;
|
downloadingFiles.value[file.name] = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/files/download', {
|
// Use proxy download endpoint for better mobile compatibility
|
||||||
params: { fileName: file.name },
|
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');
|
const link = document.createElement('a');
|
||||||
link.href = response.url;
|
link.href = proxyUrl;
|
||||||
link.target = '_blank';
|
|
||||||
|
|
||||||
// Extract clean filename for download
|
// Extract clean filename for download
|
||||||
let filename = file.displayName;
|
let filename = file.displayName;
|
||||||
|
|
@ -481,16 +518,94 @@ const downloadFile = async (file: FileItem) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
|
link.style.display = 'none';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
setTimeout(() => {
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to generate download link');
|
toast.error('Failed to download file');
|
||||||
} finally {
|
} finally {
|
||||||
downloadingFiles.value[file.name] = false;
|
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
|
// Confirm delete
|
||||||
const confirmDelete = (file: FileItem) => {
|
const confirmDelete = (file: FileItem) => {
|
||||||
fileToDelete.value = file;
|
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