866 lines
24 KiB
Vue
866 lines
24 KiB
Vue
<template>
|
|
<v-container fluid class="pa-6">
|
|
<!-- Header -->
|
|
<v-row class="mb-6" v-if="!props.selectionMode">
|
|
<v-col>
|
|
<h1 class="text-h4 font-weight-bold">
|
|
File Browser
|
|
</h1>
|
|
<p class="text-subtitle-1 text-grey mt-1">
|
|
Manage your NDA documents and other files
|
|
</p>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Selection Mode Header -->
|
|
<v-row v-if="props.selectionMode" class="mb-4">
|
|
<v-col>
|
|
<h2 class="text-h5 font-weight-bold">
|
|
Select Files to Attach
|
|
</h2>
|
|
<p class="text-subtitle-2 text-grey mt-1">
|
|
Click on files to attach them to your email
|
|
</p>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Breadcrumb Navigation -->
|
|
<v-row class="mb-4" v-if="currentPath && !props.selectionMode">
|
|
<v-col>
|
|
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
|
|
<template v-slot:item="{ item }">
|
|
<v-breadcrumbs-item
|
|
@click="navigateToFolder((item as any).path)"
|
|
class="cursor-pointer"
|
|
>
|
|
{{ item.title }}
|
|
</v-breadcrumbs-item>
|
|
</template>
|
|
</v-breadcrumbs>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Action Bar -->
|
|
<v-row class="mb-4">
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="searchQuery"
|
|
placeholder="Search files..."
|
|
prepend-inner-icon="mdi-magnify"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
clearable
|
|
@update:model-value="filterFiles"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6" class="d-flex justify-end ga-2" v-if="!props.selectionMode">
|
|
<v-btn
|
|
color="secondary"
|
|
size="large"
|
|
@click="newFolderDialog = true"
|
|
prepend-icon="mdi-folder-plus"
|
|
variant="outlined"
|
|
>
|
|
New Folder
|
|
</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
size="large"
|
|
@click="uploadDialog = true"
|
|
prepend-icon="mdi-upload"
|
|
>
|
|
Upload Files
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Bulk Actions Bar (shown when items selected) -->
|
|
<v-row v-if="selectedItems.length > 0 && !props.selectionMode" 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="selectionMode ? headersSelectionMode : headers"
|
|
:items="filteredFiles"
|
|
:loading="loading"
|
|
:items-per-page="25"
|
|
class="elevation-0"
|
|
:show-select="!selectionMode"
|
|
item-value="name"
|
|
>
|
|
<!-- Custom checkbox for selection mode -->
|
|
<template v-if="selectionMode" v-slot:item.checkbox="{ item }">
|
|
<v-checkbox
|
|
:model-value="isSelected(item)"
|
|
@update:model-value="toggleSelection(item)"
|
|
:disabled="item.isFolder"
|
|
hide-details
|
|
density="compact"
|
|
/>
|
|
</template>
|
|
|
|
<template v-slot:item.displayName="{ item }">
|
|
<div
|
|
class="d-flex align-center py-2"
|
|
:class="{ 'cursor-pointer': !selectionMode || item.isFolder }"
|
|
@click="handleFileClick(item)"
|
|
>
|
|
<v-icon :icon="item.icon" class="mr-3" :color="item.isFolder ? 'primary' : ''" />
|
|
<div>
|
|
<div class="font-weight-medium">{{ item.displayName }}</div>
|
|
<div class="text-caption text-grey" v-if="!item.isFolder">
|
|
{{ item.extension.toUpperCase() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-slot:item.sizeFormatted="{ item }">
|
|
<span class="text-body-2">{{ item.sizeFormatted }}</span>
|
|
</template>
|
|
|
|
<template v-slot:item.lastModified="{ item }">
|
|
<span class="text-body-2">{{ formatDate(item.lastModified) }}</span>
|
|
</template>
|
|
|
|
<template v-slot:item.actions="{ item }">
|
|
<div class="d-flex justify-end">
|
|
<v-btn
|
|
v-if="canPreview(item)"
|
|
icon
|
|
variant="text"
|
|
size="small"
|
|
@click.stop="previewFile(item)"
|
|
>
|
|
<v-icon>mdi-eye</v-icon>
|
|
<v-tooltip activator="parent" location="top">Preview</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
v-if="!item.isFolder"
|
|
icon
|
|
variant="text"
|
|
size="small"
|
|
@click.stop="downloadFile(item)"
|
|
:loading="downloadingFiles[item.name]"
|
|
>
|
|
<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"
|
|
size="small"
|
|
color="error"
|
|
@click.stop="confirmDelete(item)"
|
|
>
|
|
<v-icon>mdi-delete</v-icon>
|
|
<v-tooltip activator="parent" location="top">Delete</v-tooltip>
|
|
</v-btn>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-slot:no-data>
|
|
<v-empty-state
|
|
icon="mdi-folder-open-outline"
|
|
title="No files found"
|
|
:text="currentPath ? 'This folder is empty' : 'Upload your first file to get started'"
|
|
class="my-6"
|
|
/>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
|
|
<!-- Upload Dialog -->
|
|
<v-dialog v-model="uploadDialog" max-width="600">
|
|
<v-card>
|
|
<v-card-title class="d-flex align-center">
|
|
<v-icon class="mr-2">mdi-upload</v-icon>
|
|
Upload Files
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<FileUploader
|
|
@upload="handleFileUpload"
|
|
@close="uploadDialog = false"
|
|
:uploading="uploading"
|
|
:current-path="currentPath"
|
|
/>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- New Folder Dialog -->
|
|
<v-dialog v-model="newFolderDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title>
|
|
<v-icon class="mr-2">mdi-folder-plus</v-icon>
|
|
Create New Folder
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-text-field
|
|
v-model="newFolderName"
|
|
label="Folder Name"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
autofocus
|
|
@keyup.enter="createNewFolder"
|
|
/>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="newFolderDialog = false">Cancel</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
@click="createNewFolder"
|
|
:loading="creatingFolder"
|
|
:disabled="!newFolderName"
|
|
>
|
|
Create
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<v-dialog v-model="deleteDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title>
|
|
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
|
|
Confirm Delete
|
|
</v-card-title>
|
|
<v-card-text>
|
|
Are you sure you want to delete "{{ fileToDelete?.displayName }}"?
|
|
<span v-if="fileToDelete?.isFolder">
|
|
This will delete all files and folders inside it.
|
|
</span>
|
|
This action cannot be undone.
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="deleteDialog = false">Cancel</v-btn>
|
|
<v-btn
|
|
color="error"
|
|
variant="flat"
|
|
@click="deleteFile"
|
|
:loading="deleting"
|
|
>
|
|
Delete
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</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"
|
|
:file="fileToPreview"
|
|
/>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'dashboard-unified'
|
|
});
|
|
|
|
import { ref, computed, onMounted, watch } from 'vue';
|
|
import FileUploader from '~/components/FileUploader.vue';
|
|
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
|
|
|
interface FileItem {
|
|
name: string;
|
|
size: number;
|
|
sizeFormatted: string;
|
|
lastModified: string;
|
|
extension: string;
|
|
icon: string;
|
|
displayName: string;
|
|
isFolder: boolean;
|
|
path?: string;
|
|
bucket?: string;
|
|
}
|
|
|
|
interface Props {
|
|
selectionMode?: boolean;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'file-selected', file: FileItem): void;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<Emits>();
|
|
|
|
const toast = useToast();
|
|
|
|
// Data
|
|
const files = ref<FileItem[]>([]);
|
|
const filteredFiles = ref<FileItem[]>([]);
|
|
const selectedItems = ref<string[]>([]);
|
|
const selectedFilesInBrowser = ref<FileItem[]>([]);
|
|
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 = [
|
|
{ title: 'Name', key: 'displayName', sortable: true },
|
|
{ title: 'Size', key: 'sizeFormatted', sortable: true },
|
|
{ title: 'Modified', key: 'lastModified', sortable: true },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' as const },
|
|
];
|
|
|
|
// Table headers for selection mode
|
|
const headersSelectionMode = [
|
|
{ title: '', key: 'checkbox', sortable: false, width: '50px' },
|
|
{ title: 'Name', key: 'displayName', sortable: true },
|
|
{ title: 'Size', key: 'sizeFormatted', sortable: true },
|
|
{ title: 'Modified', key: 'lastModified', sortable: true },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' as const },
|
|
];
|
|
|
|
// Breadcrumb items
|
|
const breadcrumbItems = computed(() => {
|
|
const items = [
|
|
{ title: 'Root', path: '', to: '#' }
|
|
];
|
|
|
|
if (currentPath.value) {
|
|
const parts = currentPath.value.split('/').filter(Boolean);
|
|
let accumulated = '';
|
|
|
|
parts.forEach(part => {
|
|
accumulated += part + '/';
|
|
items.push({
|
|
title: part,
|
|
path: accumulated,
|
|
to: '#'
|
|
});
|
|
});
|
|
}
|
|
|
|
return items;
|
|
});
|
|
|
|
// Load files with retry logic
|
|
const loadFiles = async (retryCount = 0) => {
|
|
loading.value = true;
|
|
try {
|
|
// Get current user email for email attachments
|
|
const user = useDirectusUser();
|
|
const userEmail = user.value?.email || '';
|
|
|
|
const response = await $fetch<{
|
|
success: boolean;
|
|
files: FileItem[];
|
|
count: number;
|
|
currentPath: string;
|
|
}>('/api/files/list-with-attachments', {
|
|
headers: {
|
|
'x-tag': '094ut234'
|
|
},
|
|
params: {
|
|
prefix: currentPath.value,
|
|
recursive: false,
|
|
includeEmailAttachments: true,
|
|
userEmail: userEmail
|
|
},
|
|
timeout: 15000 // 15 second timeout
|
|
});
|
|
files.value = response.files || [];
|
|
filteredFiles.value = response.files || [];
|
|
} catch (error: any) {
|
|
console.error(`Failed to load files (attempt ${retryCount + 1}/3):`, error);
|
|
|
|
// Retry on certain errors
|
|
if (retryCount < 2 && (
|
|
error.message?.includes('Failed to fetch') ||
|
|
error.message?.includes('Network') ||
|
|
error.statusCode === 500 ||
|
|
error.statusCode === 503
|
|
)) {
|
|
console.log('Retrying file load...');
|
|
setTimeout(() => {
|
|
loadFiles(retryCount + 1);
|
|
}, (retryCount + 1) * 1000); // Exponential backoff
|
|
} else {
|
|
toast.error('Failed to load files. Please refresh the page.');
|
|
files.value = [];
|
|
filteredFiles.value = [];
|
|
}
|
|
} finally {
|
|
if (retryCount === 0 || retryCount === 2) {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Filter files based on search
|
|
const filterFiles = () => {
|
|
if (!searchQuery.value) {
|
|
filteredFiles.value = files.value;
|
|
return;
|
|
}
|
|
|
|
const query = searchQuery.value.toLowerCase();
|
|
filteredFiles.value = files.value.filter(file =>
|
|
file.displayName.toLowerCase().includes(query)
|
|
);
|
|
};
|
|
|
|
// Check if file is selected
|
|
const isSelected = (item: FileItem) => {
|
|
return selectedFilesInBrowser.value.some(f => f.name === item.name);
|
|
};
|
|
|
|
// Toggle file selection
|
|
const toggleSelection = (item: FileItem) => {
|
|
if (item.isFolder) return;
|
|
|
|
const index = selectedFilesInBrowser.value.findIndex(f => f.name === item.name);
|
|
if (index > -1) {
|
|
selectedFilesInBrowser.value.splice(index, 1);
|
|
} else {
|
|
selectedFilesInBrowser.value.push(item);
|
|
}
|
|
|
|
// Emit selection event
|
|
emit('file-selected', {
|
|
...item,
|
|
path: item.name,
|
|
bucket: item.bucket || 'client-portal'
|
|
});
|
|
};
|
|
|
|
// Handle file/folder click
|
|
const handleFileClick = (item: FileItem) => {
|
|
if (props.selectionMode && !item.isFolder) {
|
|
// In selection mode, toggle selection on click
|
|
toggleSelection(item);
|
|
return;
|
|
}
|
|
|
|
if (item.isFolder) {
|
|
navigateToFolder(item.name);
|
|
} else if (canPreview(item)) {
|
|
previewFile(item);
|
|
}
|
|
};
|
|
|
|
// Navigate to folder
|
|
const navigateToFolder = (folderPath: string) => {
|
|
currentPath.value = folderPath;
|
|
searchQuery.value = '';
|
|
loadFiles();
|
|
};
|
|
|
|
// Check if file can be previewed
|
|
const canPreview = (file: FileItem) => {
|
|
if (file.isFolder) return false;
|
|
const previewableExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'pdf'];
|
|
return previewableExtensions.includes(file.extension);
|
|
};
|
|
|
|
// Preview file
|
|
const previewFile = (file: FileItem) => {
|
|
fileToPreview.value = file;
|
|
previewDialog.value = true;
|
|
};
|
|
|
|
// Handle file upload
|
|
const handleFileUpload = async (uploadedFiles: File[]) => {
|
|
uploading.value = true;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
uploadedFiles.forEach(file => {
|
|
formData.append('file', file);
|
|
});
|
|
|
|
await $fetch('/api/files/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
params: {
|
|
path: currentPath.value,
|
|
}
|
|
});
|
|
|
|
toast.success(`${uploadedFiles.length} file(s) uploaded successfully`);
|
|
uploadDialog.value = false;
|
|
await loadFiles();
|
|
} catch (error) {
|
|
toast.error('Failed to upload files');
|
|
} finally {
|
|
uploading.value = false;
|
|
}
|
|
};
|
|
|
|
// Create new folder
|
|
const createNewFolder = async () => {
|
|
if (!newFolderName.value) return;
|
|
|
|
creatingFolder.value = true;
|
|
try {
|
|
const folderPath = currentPath.value
|
|
? `${currentPath.value}${newFolderName.value}/`
|
|
: `${newFolderName.value}/`;
|
|
|
|
await $fetch('/api/files/create-folder', {
|
|
method: 'POST',
|
|
body: { folderPath },
|
|
});
|
|
|
|
toast.success('Folder created successfully');
|
|
newFolderDialog.value = false;
|
|
newFolderName.value = '';
|
|
await loadFiles();
|
|
} catch (error) {
|
|
toast.error('Failed to create folder');
|
|
} finally {
|
|
creatingFolder.value = false;
|
|
}
|
|
};
|
|
|
|
// Download file (with special handling for Safari)
|
|
const downloadFile = async (file: FileItem) => {
|
|
downloadingFiles.value[file.name] = true;
|
|
|
|
try {
|
|
// Extract clean filename for download
|
|
let filename = file.displayName;
|
|
if (!filename.includes('.') && file.extension) {
|
|
filename += '.' + file.extension;
|
|
}
|
|
|
|
// Check if Safari (iOS or desktop)
|
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
|
|
const bucket = file.bucket || 'client-portal';
|
|
|
|
if (isSafari) {
|
|
// For Safari, open in new window to force proper filename handling
|
|
const downloadUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(file.name)}&bucket=${bucket}`;
|
|
window.location.href = downloadUrl;
|
|
} else {
|
|
// For other browsers, use blob approach
|
|
const response = await fetch(`/api/files/proxy-download?fileName=${encodeURIComponent(file.name)}&bucket=${bucket}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to download file');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
|
|
// Create object URL from blob
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
|
|
// Create a link element
|
|
const link = document.createElement('a');
|
|
link.href = objectUrl;
|
|
link.download = filename;
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
|
|
// Clean up
|
|
setTimeout(() => {
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(objectUrl);
|
|
}, 100);
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to download file');
|
|
} finally {
|
|
// Add delay for Safari to prevent immediate loading state removal
|
|
setTimeout(() => {
|
|
downloadingFiles.value[file.name] = false;
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// 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,
|
|
bucket: item.bucket || 'client-portal',
|
|
},
|
|
});
|
|
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;
|
|
deleteDialog.value = true;
|
|
};
|
|
|
|
// Delete file
|
|
const deleteFile = async () => {
|
|
if (!fileToDelete.value) return;
|
|
|
|
deleting.value = true;
|
|
try {
|
|
await $fetch('/api/files/delete', {
|
|
method: 'POST',
|
|
body: {
|
|
fileName: fileToDelete.value.name,
|
|
isFolder: fileToDelete.value.isFolder,
|
|
bucket: fileToDelete.value.bucket || 'client-portal',
|
|
},
|
|
});
|
|
|
|
toast.success(fileToDelete.value.isFolder ? 'Folder deleted successfully' : 'File deleted successfully');
|
|
deleteDialog.value = false;
|
|
await loadFiles();
|
|
} catch (error) {
|
|
toast.error('Failed to delete');
|
|
} finally {
|
|
deleting.value = false;
|
|
fileToDelete.value = null;
|
|
}
|
|
};
|
|
|
|
// 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', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
// Show selection count at the bottom in selection mode
|
|
const selectionCount = computed(() => {
|
|
return selectedFilesInBrowser.value.length;
|
|
});
|
|
|
|
// Load files on mount
|
|
onMounted(() => {
|
|
loadFiles();
|
|
});
|
|
|
|
// Add selection counter at the bottom for selection mode
|
|
if (props.selectionMode) {
|
|
watch(selectionCount, (count) => {
|
|
console.log('[FileBrowser] Selection count:', count);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.cursor-pointer {
|
|
cursor: pointer;
|
|
}
|
|
</style>
|