This commit is contained in:
Matt 2025-06-10 15:21:42 +02:00
parent 4b6d3fd991
commit c6b4c716a8
5 changed files with 170 additions and 36 deletions

View File

@ -274,21 +274,21 @@
</v-btn>
</div>
<!-- Selected Files Preview -->
<div v-if="selectedBrowserFiles.length > 0" class="mt-3">
<div class="text-caption mb-2">Selected from browser:</div>
<!-- All Attachments Preview -->
<div v-if="allAttachments.length > 0" class="mt-3">
<div class="text-caption mb-2">Attachments ({{ allAttachments.length }}):</div>
<v-chip
v-for="(file, i) in selectedBrowserFiles"
:key="i"
v-for="(attachment, i) in allAttachments"
:key="`${attachment.type}-${i}`"
size="small"
color="primary"
variant="tonal"
closable
@click:close="removeBrowserFile(i)"
@click:close="removeAttachment(attachment)"
class="mr-2 mb-2"
>
<v-icon start size="small">mdi-file</v-icon>
{{ file.name }}
<v-icon start size="small">{{ attachment.type === 'uploaded' ? 'mdi-upload' : 'mdi-file' }}</v-icon>
{{ attachment.name }}
</v-chip>
</div>
</div>
@ -387,6 +387,25 @@ const emailDraft = ref<{
attachments: []
});
// Combined attachments from both upload and browser
const allAttachments = computed(() => {
const uploaded = emailDraft.value.attachments.map((file, index) => ({
type: 'uploaded',
file,
name: file.name,
index
}));
const browser = selectedBrowserFiles.value.map((file, index) => ({
type: 'browser',
file,
name: file.displayName || file.name,
index
}));
return [...uploaded, ...browser];
});
// Check for stored session on mount
onMounted(() => {
const storedSession = localStorage.getItem('emailSessionId');
@ -444,6 +463,7 @@ const loadEmailThread = async () => {
const response = await $fetch<{
success: boolean;
emails: any[];
threads?: any[];
}>('/api/email/fetch-thread', {
method: 'POST',
headers: {
@ -459,6 +479,12 @@ const loadEmailThread = async () => {
if (response.success) {
console.log('[ClientEmailSection] Successfully loaded', response.emails?.length || 0, 'emails');
emailThreads.value = response.emails || [];
// Check if we have threads from the API response
if (response.threads) {
console.log('[ClientEmailSection] Threads available:', response.threads.length);
// For now, still use emails until we implement thread UI
}
}
} catch (error) {
console.error('[ClientEmailSection] Failed to load email thread:', error);
@ -538,6 +564,8 @@ const sendEmail = async () => {
if (emailDraft.value.attachments && emailDraft.value.attachments.length > 0) {
for (const file of emailDraft.value.attachments) {
try {
console.log('[ClientEmailSection] Uploading file:', file.name, 'size:', file.size);
const formData = new FormData();
formData.append('file', file);
@ -548,6 +576,8 @@ const sendEmail = async () => {
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
console.log('[ClientEmailSection] Upload path:', `${username}-attachments/`, 'bucket: client-emails');
const uploadResponse = await $fetch<{
success: boolean;
path: string;
@ -564,15 +594,25 @@ const sendEmail = async () => {
body: formData
});
console.log('[ClientEmailSection] Upload response:', uploadResponse);
if (uploadResponse.success) {
const attachmentPath = uploadResponse.path || uploadResponse.fileName;
console.log('[ClientEmailSection] Successfully uploaded, path:', attachmentPath);
uploadedAttachments.push({
name: file.name,
path: uploadResponse.path
path: attachmentPath,
bucket: 'client-emails'
});
} else {
console.error('[ClientEmailSection] Upload failed, response:', uploadResponse);
toast.error(`Failed to upload ${file.name}`);
}
} catch (uploadError) {
console.error('Failed to upload attachment:', uploadError);
toast.error(`Failed to upload ${file.name}`);
} catch (uploadError: any) {
console.error('[ClientEmailSection] Failed to upload attachment:', file.name, uploadError);
console.error('[ClientEmailSection] Upload error details:', uploadError.data || uploadError.message);
toast.error(`Failed to upload ${file.name}: ${uploadError.data?.statusMessage || uploadError.message || 'Unknown error'}`);
}
}
}
@ -622,14 +662,19 @@ const sendEmail = async () => {
localStorage.setItem('emailSignature', JSON.stringify(signatureConfig.value));
}
// Add to thread
// Add to thread with all attachments info
const allAttachmentNames = [
...emailDraft.value.attachments.map((f: File) => ({ name: f.name })),
...selectedBrowserFiles.value.map((f: any) => ({ name: f.displayName || f.name }))
];
emailThreads.value.unshift({
direction: 'sent',
to: props.interest['Email Address'],
subject: emailDraft.value.subject,
content: emailDraft.value.content,
timestamp: new Date().toISOString(),
attachments: emailDraft.value.attachments.map((f: File) => ({ name: f.name }))
attachments: allAttachmentNames
});
// Close composer and reset
@ -661,6 +706,16 @@ const removeBrowserFile = (index: number) => {
selectedBrowserFiles.value = selectedBrowserFiles.value.filter((_, i) => i !== index);
};
const removeAttachment = (attachment: any) => {
if (attachment.type === 'uploaded') {
// Remove from uploaded files
emailDraft.value.attachments = emailDraft.value.attachments.filter((_, i) => i !== attachment.index);
} else if (attachment.type === 'browser') {
// Remove from browser-selected files
selectedBrowserFiles.value = selectedBrowserFiles.value.filter((_, i) => i !== attachment.index);
}
};
const onCredentialsSaved = (data: { sessionId: string }) => {
sessionId.value = data.sessionId;
localStorage.setItem('emailSessionId', data.sessionId);

View File

@ -94,6 +94,7 @@ interface FileItem {
icon: string;
displayName: string;
isFolder: boolean;
bucket?: string;
}
interface Props {
@ -163,8 +164,9 @@ const loadPreview = async () => {
try {
// For images and PDFs, fetch as blob and create object URL
if (isImage.value || isPdf.value) {
// Fetch the file as a blob
const response = await fetch(`/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}`);
// Fetch the file as a blob, including bucket if specified
const bucket = props.file.bucket || 'client-portal';
const response = await fetch(`/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}&bucket=${bucket}`);
if (!response.ok) {
throw new Error('Failed to fetch file');
@ -213,13 +215,15 @@ const downloadFile = async () => {
// Check if Safari (iOS or desktop)
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const bucket = props.file.bucket || 'client-portal';
if (isSafari) {
// For Safari, use location.href to force proper filename handling
const downloadUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(props.file.name)}`;
const downloadUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(props.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(props.file.name)}`);
const response = await fetch(`/api/files/proxy-download?fileName=${encodeURIComponent(props.file.name)}&bucket=${bucket}`);
if (!response.ok) {
throw new Error('Failed to download file');

View File

@ -113,17 +113,29 @@
<v-card>
<v-data-table
v-model="selectedItems"
:headers="headers"
:headers="selectionMode ? headersSelectionMode : headers"
:items="filteredFiles"
:loading="loading"
:items-per-page="25"
class="elevation-0"
show-select
: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 cursor-pointer"
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' : ''" />
@ -338,6 +350,7 @@ interface FileItem {
displayName: string;
isFolder: boolean;
path?: string;
bucket?: string;
}
interface Props {
@ -357,6 +370,7 @@ const toast = useToast();
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);
@ -384,6 +398,15 @@ const headers = [
{ 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 = [
@ -473,14 +496,35 @@ const filterFiles = () => {
);
};
// 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, emit the file for attachment
emit('file-selected', {
...item,
path: item.name
});
// In selection mode, toggle selection on click
toggleSelection(item);
return;
}
@ -788,10 +832,22 @@ const formatDate = (date: string) => {
});
};
// 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>

View File

@ -1,9 +1,10 @@
import { getDownloadUrl } from '~/server/utils/minio';
import { getMinioClient } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const fileName = query.fileName as string;
const bucket = (query.bucket as string) || 'client-portal'; // Support bucket parameter
if (!fileName) {
throw createError({
@ -18,10 +19,26 @@ export default defineEventHandler(async (event) => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
console.log(`[proxy-download] Attempting to download ${fileName} (attempt ${attempt + 1}/3)`);
console.log(`[proxy-download] Attempting to download ${fileName} from bucket ${bucket} (attempt ${attempt + 1}/3)`);
// Get the download URL from MinIO
const url = await getDownloadUrl(fileName);
// Get the download URL from MinIO with the correct bucket
const client = getMinioClient();
// Extract just the filename for the download header
let filename = fileName.split('/').pop() || fileName;
// Remove timestamp prefix if present
const timestampMatch = filename.match(/^\d{10,}-(.+)$/);
if (timestampMatch) {
filename = timestampMatch[1];
}
// Generate presigned URL with download headers
const responseHeaders = {
'response-content-disposition': `attachment; filename="${filename}"`,
};
const url = await client.presignedGetObject(bucket, fileName, 60 * 60, responseHeaders);
// Fetch the file from MinIO with timeout
const controller = new AbortController();

View File

@ -1,12 +1,13 @@
import { getDownloadUrl } from '~/server/utils/minio';
import { getMinioClient } from '~/server/utils/minio';
import mime from 'mime-types';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const fileName = query.fileName as string;
const bucket = (query.bucket as string) || 'client-portal'; // Support bucket parameter
console.log('Proxy preview requested for:', fileName);
console.log('Proxy preview requested for:', fileName, 'in bucket:', bucket);
if (!fileName) {
throw createError({
@ -19,9 +20,10 @@ export default defineEventHandler(async (event) => {
const contentType = mime.lookup(fileName) || 'application/octet-stream';
console.log('Content type:', contentType);
// Get the download URL
const url = await getDownloadUrl(fileName);
console.log('MinIO URL obtained');
// Get the download URL with the correct bucket
const client = getMinioClient();
const url = await client.presignedGetObject(bucket, fileName, 60 * 60);
console.log('MinIO URL obtained for bucket:', bucket);
// Fetch the file content from MinIO
const response = await fetch(url);
@ -44,7 +46,7 @@ export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', contentType);
setHeader(event, 'Content-Disposition', `inline; filename="${fileName.split('/').pop()}"`);
setHeader(event, 'Cache-Control', 'public, max-age=3600');
setHeader(event, 'Content-Length', String(buffer.length));
setHeader(event, 'Content-Length', buffer.length);
// For PDF files, add additional headers
if (contentType === 'application/pdf') {