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

View File

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

View File

@ -113,17 +113,29 @@
<v-card> <v-card>
<v-data-table <v-data-table
v-model="selectedItems" v-model="selectedItems"
:headers="headers" :headers="selectionMode ? headersSelectionMode : headers"
:items="filteredFiles" :items="filteredFiles"
:loading="loading" :loading="loading"
:items-per-page="25" :items-per-page="25"
class="elevation-0" class="elevation-0"
show-select :show-select="!selectionMode"
item-value="name" 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 }"> <template v-slot:item.displayName="{ item }">
<div <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)" @click="handleFileClick(item)"
> >
<v-icon :icon="item.icon" class="mr-3" :color="item.isFolder ? 'primary' : ''" /> <v-icon :icon="item.icon" class="mr-3" :color="item.isFolder ? 'primary' : ''" />
@ -338,6 +350,7 @@ interface FileItem {
displayName: string; displayName: string;
isFolder: boolean; isFolder: boolean;
path?: string; path?: string;
bucket?: string;
} }
interface Props { interface Props {
@ -357,6 +370,7 @@ const toast = useToast();
const files = ref<FileItem[]>([]); const files = ref<FileItem[]>([]);
const filteredFiles = ref<FileItem[]>([]); const filteredFiles = ref<FileItem[]>([]);
const selectedItems = ref<string[]>([]); const selectedItems = ref<string[]>([]);
const selectedFilesInBrowser = ref<FileItem[]>([]);
const searchQuery = ref(''); const searchQuery = ref('');
const loading = ref(false); const loading = ref(false);
const uploading = ref(false); const uploading = ref(false);
@ -384,6 +398,15 @@ const headers = [
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' as const }, { 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 // Breadcrumb items
const breadcrumbItems = computed(() => { const breadcrumbItems = computed(() => {
const items = [ 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 // Handle file/folder click
const handleFileClick = (item: FileItem) => { const handleFileClick = (item: FileItem) => {
if (props.selectionMode && !item.isFolder) { if (props.selectionMode && !item.isFolder) {
// In selection mode, emit the file for attachment // In selection mode, toggle selection on click
emit('file-selected', { toggleSelection(item);
...item,
path: item.name
});
return; 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 // Load files on mount
onMounted(() => { onMounted(() => {
loadFiles(); loadFiles();
}); });
// Add selection counter at the bottom for selection mode
if (props.selectionMode) {
watch(selectionCount, (count) => {
console.log('[FileBrowser] Selection count:', count);
});
}
</script> </script>
<style scoped> <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) => { export default defineEventHandler(async (event) => {
try { try {
const query = getQuery(event); const query = getQuery(event);
const fileName = query.fileName as string; const fileName = query.fileName as string;
const bucket = (query.bucket as string) || 'client-portal'; // Support bucket parameter
if (!fileName) { if (!fileName) {
throw createError({ throw createError({
@ -18,10 +19,26 @@ export default defineEventHandler(async (event) => {
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { 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 // Get the download URL from MinIO with the correct bucket
const url = await getDownloadUrl(fileName); 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 // Fetch the file from MinIO with timeout
const controller = new AbortController(); 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'; import mime from 'mime-types';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const query = getQuery(event); const query = getQuery(event);
const fileName = query.fileName as string; 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) { if (!fileName) {
throw createError({ throw createError({
@ -19,9 +20,10 @@ export default defineEventHandler(async (event) => {
const contentType = mime.lookup(fileName) || 'application/octet-stream'; const contentType = mime.lookup(fileName) || 'application/octet-stream';
console.log('Content type:', contentType); console.log('Content type:', contentType);
// Get the download URL // Get the download URL with the correct bucket
const url = await getDownloadUrl(fileName); const client = getMinioClient();
console.log('MinIO URL obtained'); const url = await client.presignedGetObject(bucket, fileName, 60 * 60);
console.log('MinIO URL obtained for bucket:', bucket);
// Fetch the file content from MinIO // Fetch the file content from MinIO
const response = await fetch(url); const response = await fetch(url);
@ -44,7 +46,7 @@ export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', contentType); setHeader(event, 'Content-Type', contentType);
setHeader(event, 'Content-Disposition', `inline; filename="${fileName.split('/').pop()}"`); setHeader(event, 'Content-Disposition', `inline; filename="${fileName.split('/').pop()}"`);
setHeader(event, 'Cache-Control', 'public, max-age=3600'); 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 // For PDF files, add additional headers
if (contentType === 'application/pdf') { if (contentType === 'application/pdf') {