Add file rename functionality and improve preview handling

- Implement file/folder rename feature with dialog and API endpoint
- Add rename button to file browser with keyboard shortcuts
- Switch PDF preview from object to embed tag for better compatibility
- Fix CORS issues by fetching preview files as blobs with object URLs
- Add proper cleanup for object URLs to prevent memory leaks
- Add renameObject utility function for MinIO operations
This commit is contained in:
Matt 2025-06-04 18:14:00 +02:00
parent 673b6c6748
commit bac1bb2b5e
4 changed files with 317 additions and 16 deletions

View File

@ -50,16 +50,12 @@
<!-- PDF Preview -->
<div v-else-if="isPdf" class="pdf-preview-container">
<object
:data="previewUrl"
<embed
:src="previewUrl"
type="application/pdf"
width="100%"
height="100%"
@load="loading = false"
@error="handlePreviewError"
>
<p>PDF preview is not available. <a :href="previewUrl" target="_blank">Click here to view the PDF</a>.</p>
</object>
/>
</div>
</v-card-text>
@ -86,7 +82,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onUnmounted } from 'vue';
interface FileItem {
name: string;
@ -114,6 +110,7 @@ const emit = defineEmits<Emits>();
const loading = ref(false);
const error = ref('');
const previewUrl = ref('');
const objectUrl = ref('');
// Computed property for v-model binding
const isOpen = computed({
@ -156,19 +153,39 @@ const loadPreview = async () => {
error.value = '';
previewUrl.value = '';
// Clean up previous object URL if exists
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
objectUrl.value = '';
}
try {
// For images and PDFs, use the proxy endpoint to avoid CORS issues
// For images and PDFs, fetch as blob and create object URL
if (isImage.value || isPdf.value) {
// Use the proxy endpoint that serves the file directly
const proxyUrl = `/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}`;
console.log('Setting preview URL to:', proxyUrl);
previewUrl.value = proxyUrl;
// The loading state will be handled by the image/iframe onload event
// Fetch the file as a blob
const response = await fetch(`/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}`);
if (!response.ok) {
throw new Error('Failed to fetch file');
}
const blob = await response.blob();
// Create object URL from blob
objectUrl.value = URL.createObjectURL(blob);
previewUrl.value = objectUrl.value;
console.log('Created object URL for preview:', objectUrl.value);
// Set loading to false after a short delay to allow the component to render
setTimeout(() => {
loading.value = false;
}, 100);
} else {
throw new Error('File type does not support preview');
}
} catch (err: any) {
error.value = err.data?.statusMessage || err.message || 'Failed to load preview';
error.value = err.message || 'Failed to load preview';
loading.value = false;
}
};
@ -201,7 +218,20 @@ const closeModal = () => {
isOpen.value = false;
previewUrl.value = '';
error.value = '';
// Clean up object URL
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
objectUrl.value = '';
}
};
// Clean up on unmount
onUnmounted(() => {
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
}
});
</script>
<style scoped>

View File

@ -118,6 +118,15 @@
<v-icon>mdi-download</v-icon>
<v-tooltip activator="parent" location="top">Download</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
size="small"
@click.stop="confirmRename(item)"
>
<v-icon>mdi-rename-box</v-icon>
<v-tooltip activator="parent" location="top">Rename</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
@ -222,6 +231,41 @@
</v-card>
</v-dialog>
<!-- Rename Dialog -->
<v-dialog v-model="renameDialog" max-width="400">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-rename-box</v-icon>
Rename {{ fileToRename?.isFolder ? 'Folder' : 'File' }}
</v-card-title>
<v-card-text>
<v-text-field
v-model="newName"
:label="fileToRename?.isFolder ? 'New folder name' : 'New file name (without extension)'"
variant="outlined"
density="comfortable"
autofocus
@keyup.enter="renameFile"
:hint="fileToRename?.isFolder ? '' : `Extension will be preserved (.${fileToRename?.extension})`"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="renameDialog = false">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
@click="renameFile"
:loading="renaming"
:disabled="!newName || newName === getDisplayNameForRename()"
>
Rename
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- File Preview Dialog -->
<FilePreviewModal
v-model="previewDialog"
@ -255,16 +299,20 @@ const searchQuery = ref('');
const loading = ref(false);
const uploading = ref(false);
const deleting = ref(false);
const renaming = ref(false);
const creatingFolder = ref(false);
const uploadDialog = ref(false);
const deleteDialog = ref(false);
const newFolderDialog = ref(false);
const previewDialog = ref(false);
const renameDialog = ref(false);
const fileToDelete = ref<FileItem | null>(null);
const fileToPreview = ref<FileItem | null>(null);
const fileToRename = ref<FileItem | null>(null);
const downloadingFiles = ref<Record<string, boolean>>({});
const currentPath = ref('');
const newFolderName = ref('');
const newName = ref('');
// Table headers
const headers = [
@ -461,6 +509,59 @@ const deleteFile = async () => {
}
};
// Confirm rename
const confirmRename = (file: FileItem) => {
fileToRename.value = file;
// Set initial value to current display name without extension
if (file.isFolder) {
newName.value = file.displayName;
} else {
// Remove extension from display name
const extIndex = file.displayName.lastIndexOf('.');
newName.value = extIndex > -1 ? file.displayName.substring(0, extIndex) : file.displayName;
}
renameDialog.value = true;
};
// Get display name for rename comparison
const getDisplayNameForRename = () => {
if (!fileToRename.value) return '';
if (fileToRename.value.isFolder) {
return fileToRename.value.displayName;
} else {
// Remove extension from display name
const extIndex = fileToRename.value.displayName.lastIndexOf('.');
return extIndex > -1 ? fileToRename.value.displayName.substring(0, extIndex) : fileToRename.value.displayName;
}
};
// Rename file
const renameFile = async () => {
if (!fileToRename.value || !newName.value) return;
renaming.value = true;
try {
await $fetch('/api/files/rename', {
method: 'POST',
body: {
oldName: fileToRename.value.name,
newName: newName.value,
isFolder: fileToRename.value.isFolder,
},
});
toast.success(fileToRename.value.isFolder ? 'Folder renamed successfully' : 'File renamed successfully');
renameDialog.value = false;
await loadFiles();
} catch (error: any) {
toast.error(error.data?.statusMessage || 'Failed to rename');
} finally {
renaming.value = false;
fileToRename.value = null;
newName.value = '';
}
};
// Helpers
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', {

View File

@ -0,0 +1,93 @@
import { renameFile, renameFolder } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { oldName, newName, isFolder } = body;
if (!oldName || !newName) {
throw createError({
statusCode: 400,
statusMessage: 'Old name and new name are required',
});
}
// Validate new name doesn't contain invalid characters
const invalidChars = /[<>:"|?*\\]/g;
if (invalidChars.test(newName)) {
throw createError({
statusCode: 400,
statusMessage: 'File name contains invalid characters',
});
}
// For files, we need to preserve the timestamp prefix and path
let oldPath = oldName;
let newPath = '';
if (!isFolder) {
// Extract the directory path and filename
const lastSlash = oldName.lastIndexOf('/');
const directory = lastSlash > -1 ? oldName.substring(0, lastSlash + 1) : '';
const oldFilename = lastSlash > -1 ? oldName.substring(lastSlash + 1) : oldName;
// Extract timestamp prefix if present
const timestampMatch = oldFilename.match(/^(\d{10,})-(.+)$/);
let timestamp = '';
if (timestampMatch) {
timestamp = timestampMatch[1] + '-';
}
// Preserve file extension
const oldExtIndex = oldFilename.lastIndexOf('.');
const extension = oldExtIndex > -1 ? oldFilename.substring(oldExtIndex) : '';
// Remove extension from new name if user included it
let cleanNewName = newName;
if (newName.endsWith(extension)) {
cleanNewName = newName.substring(0, newName.length - extension.length);
}
// Construct new path with preserved timestamp and extension
newPath = directory + timestamp + cleanNewName + extension;
} else {
// For folders, handle the path construction
const lastSlash = oldName.lastIndexOf('/', oldName.length - 2);
const directory = lastSlash > -1 ? oldName.substring(0, lastSlash + 1) : '';
// Ensure folder name ends with /
const folderName = newName.endsWith('/') ? newName : newName + '/';
newPath = directory + folderName;
}
// Check if new name already exists (you might want to implement this check)
// const exists = await checkFileExists(newPath);
// if (exists) {
// throw createError({
// statusCode: 409,
// statusMessage: 'A file or folder with this name already exists',
// });
// }
// Perform the rename
if (isFolder) {
await renameFolder(oldPath, newPath);
} else {
await renameFile(oldPath, newPath);
}
return {
success: true,
oldPath,
newPath,
message: `Successfully renamed ${isFolder ? 'folder' : 'file'}`,
};
} catch (error: any) {
console.error('Failed to rename:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to rename',
});
}
});

View File

@ -136,7 +136,13 @@ export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60)
const bucketName = useRuntimeConfig().minio.bucketName;
// Extract just the filename from the full path
const filename = fileName.split('/').pop() || fileName;
let filename = fileName.split('/').pop() || fileName;
// Remove timestamp prefix if present (e.g., "1234567890-filename.pdf" -> "filename.pdf")
const timestampMatch = filename.match(/^\d{10,}-(.+)$/);
if (timestampMatch) {
filename = timestampMatch[1];
}
// Force download with Content-Disposition header
const responseHeaders = {
@ -220,3 +226,74 @@ export const getPreviewUrl = async (fileName: string, contentType: string) => {
return await client.presignedGetObject(bucketName, fileName, 60 * 60, responseHeaders);
};
// Rename file (copy and delete)
export const renameFile = async (oldPath: string, newPath: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
try {
// Copy the object to the new name
await client.copyObject(
bucketName,
newPath,
`/${bucketName}/${oldPath}`
);
// Delete the old object
await client.removeObject(bucketName, oldPath);
return true;
} catch (error) {
console.error('Error renaming file:', error);
throw error;
}
};
// Rename folder (copy all contents and delete)
export const renameFolder = async (oldPath: string, newPath: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// Ensure paths end with /
const oldPrefix = oldPath.endsWith('/') ? oldPath : oldPath + '/';
const newPrefix = newPath.endsWith('/') ? newPath : newPath + '/';
// List all objects in the folder
const objectsList: string[] = [];
return new Promise((resolve, reject) => {
const stream = client.listObjectsV2(bucketName, oldPrefix, true);
stream.on('data', (obj) => {
if (obj && obj.name) {
objectsList.push(obj.name);
}
});
stream.on('error', reject);
stream.on('end', async () => {
try {
// Copy all objects to new location
for (const objectName of objectsList) {
const newObjectName = objectName.replace(oldPrefix, newPrefix);
await client.copyObject(
bucketName,
newObjectName,
`/${bucketName}/${objectName}`
);
}
// Delete all old objects
if (objectsList.length > 0) {
await client.removeObjects(bucketName, objectsList);
}
resolve(true);
} catch (error) {
reject(error);
}
});
});
};