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:
parent
673b6c6748
commit
bac1bb2b5e
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue