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

View File

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

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