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 -->
|
<!-- PDF Preview -->
|
||||||
<div v-else-if="isPdf" class="pdf-preview-container">
|
<div v-else-if="isPdf" class="pdf-preview-container">
|
||||||
<object
|
<embed
|
||||||
:data="previewUrl"
|
:src="previewUrl"
|
||||||
type="application/pdf"
|
type="application/pdf"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="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>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
|
|
@ -86,7 +82,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -114,6 +110,7 @@ const emit = defineEmits<Emits>();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const previewUrl = ref('');
|
const previewUrl = ref('');
|
||||||
|
const objectUrl = ref('');
|
||||||
|
|
||||||
// Computed property for v-model binding
|
// Computed property for v-model binding
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
|
|
@ -156,19 +153,39 @@ const loadPreview = async () => {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
previewUrl.value = '';
|
previewUrl.value = '';
|
||||||
|
|
||||||
|
// Clean up previous object URL if exists
|
||||||
|
if (objectUrl.value) {
|
||||||
|
URL.revokeObjectURL(objectUrl.value);
|
||||||
|
objectUrl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (isImage.value || isPdf.value) {
|
||||||
// Use the proxy endpoint that serves the file directly
|
// Fetch the file as a blob
|
||||||
const proxyUrl = `/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}`;
|
const response = await fetch(`/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}`);
|
||||||
console.log('Setting preview URL to:', proxyUrl);
|
|
||||||
previewUrl.value = proxyUrl;
|
if (!response.ok) {
|
||||||
// The loading state will be handled by the image/iframe onload event
|
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 {
|
} else {
|
||||||
throw new Error('File type does not support preview');
|
throw new Error('File type does not support preview');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} 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;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -201,7 +218,20 @@ const closeModal = () => {
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
previewUrl.value = '';
|
previewUrl.value = '';
|
||||||
error.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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,15 @@
|
||||||
<v-icon>mdi-download</v-icon>
|
<v-icon>mdi-download</v-icon>
|
||||||
<v-tooltip activator="parent" location="top">Download</v-tooltip>
|
<v-tooltip activator="parent" location="top">Download</v-tooltip>
|
||||||
</v-btn>
|
</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
|
<v-btn
|
||||||
icon
|
icon
|
||||||
variant="text"
|
variant="text"
|
||||||
|
|
@ -222,6 +231,41 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</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 -->
|
<!-- File Preview Dialog -->
|
||||||
<FilePreviewModal
|
<FilePreviewModal
|
||||||
v-model="previewDialog"
|
v-model="previewDialog"
|
||||||
|
|
@ -255,16 +299,20 @@ const searchQuery = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const deleting = ref(false);
|
const deleting = ref(false);
|
||||||
|
const renaming = ref(false);
|
||||||
const creatingFolder = ref(false);
|
const creatingFolder = ref(false);
|
||||||
const uploadDialog = ref(false);
|
const uploadDialog = ref(false);
|
||||||
const deleteDialog = ref(false);
|
const deleteDialog = ref(false);
|
||||||
const newFolderDialog = ref(false);
|
const newFolderDialog = ref(false);
|
||||||
const previewDialog = ref(false);
|
const previewDialog = ref(false);
|
||||||
|
const renameDialog = ref(false);
|
||||||
const fileToDelete = ref<FileItem | null>(null);
|
const fileToDelete = ref<FileItem | null>(null);
|
||||||
const fileToPreview = ref<FileItem | null>(null);
|
const fileToPreview = ref<FileItem | null>(null);
|
||||||
|
const fileToRename = ref<FileItem | null>(null);
|
||||||
const downloadingFiles = ref<Record<string, boolean>>({});
|
const downloadingFiles = ref<Record<string, boolean>>({});
|
||||||
const currentPath = ref('');
|
const currentPath = ref('');
|
||||||
const newFolderName = ref('');
|
const newFolderName = ref('');
|
||||||
|
const newName = ref('');
|
||||||
|
|
||||||
// Table headers
|
// Table headers
|
||||||
const 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
|
// Helpers
|
||||||
const formatDate = (date: string) => {
|
const formatDate = (date: string) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
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;
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
// Extract just the filename from the full path
|
// 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
|
// Force download with Content-Disposition header
|
||||||
const responseHeaders = {
|
const responseHeaders = {
|
||||||
|
|
@ -220,3 +226,74 @@ export const getPreviewUrl = async (fileName: string, contentType: string) => {
|
||||||
|
|
||||||
return await client.presignedGetObject(bucketName, fileName, 60 * 60, responseHeaders);
|
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