updates
This commit is contained in:
parent
4b6d3fd991
commit
c6b4c716a8
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue