port-nimara-client-portal/pages/dashboard/file-browser.vue

866 lines
24 KiB
Vue
Raw Normal View History

<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"
2025-06-10 15:21:42 +02:00
:headers="selectionMode ? headersSelectionMode : headers"
:items="filteredFiles"
:loading="loading"
:items-per-page="25"
class="elevation-0"
2025-06-10 15:21:42 +02:00
:show-select="!selectionMode"
item-value="name"
>
2025-06-10 15:21:42 +02:00
<!-- 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
2025-06-10 15:21:42 +02:00
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;
2025-06-10 15:21:42 +02:00
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[]>([]);
2025-06-10 15:21:42 +02:00
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 },
];
2025-06-10 15:21:42 +02:00
// 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 {
2025-06-10 14:32:20 +02:00
// 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,
2025-06-10 14:32:20 +02:00
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)
);
};
2025-06-10 15:21:42 +02:00
// 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) {
2025-06-10 15:21:42 +02:00
// 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);
2025-06-10 15:33:01 +02:00
const bucket = file.bucket || 'client-portal';
if (isSafari) {
// For Safari, open in new window to force proper filename handling
2025-06-10 15:33:01 +02:00
const downloadUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(file.name)}&bucket=${bucket}`;
window.location.href = downloadUrl;
} else {
// For other browsers, use blob approach
2025-06-10 15:33:01 +02:00
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,
2025-06-10 15:33:01 +02:00
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,
2025-06-10 15:27:57 +02:00
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',
});
};
2025-06-10 15:21:42 +02:00
// Show selection count at the bottom in selection mode
const selectionCount = computed(() => {
return selectedFilesInBrowser.value.length;
});
// Load files on mount
onMounted(() => {
loadFiles();
});
2025-06-10 15:21:42 +02:00
// 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>