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:
Matt 2025-06-04 18:37:41 +02:00
parent 1463fdb3d7
commit b7544d82f3
3 changed files with 190 additions and 16 deletions

View File

@ -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);
}

View File

@ -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;

View 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',
});
}
});