diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5e901d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#221489", + "titleBar.activeBackground": "#301DC0", + "titleBar.activeForeground": "#F9F9FE" + } +} \ No newline at end of file diff --git a/src/components/dashboard/reimbursement/ReimbursementList.tsx b/src/components/dashboard/reimbursement/ReimbursementList.tsx index baa9ce7..5654ab8 100644 --- a/src/components/dashboard/reimbursement/ReimbursementList.tsx +++ b/src/components/dashboard/reimbursement/ReimbursementList.tsx @@ -8,6 +8,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase'; import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { Collections } from '../../../schemas/pocketbase/schema'; +import { toast } from 'react-hot-toast'; interface AuditNote { note: string; @@ -121,6 +122,20 @@ export default function ReimbursementList() { console.log('Number of requests:', requests.length); }, [requests]); + // Add a useEffect to log preview URL and filename changes + useEffect(() => { + console.log('Preview URL changed:', previewUrl); + console.log('Preview filename changed:', previewFilename); + }, [previewUrl, previewFilename]); + + // Add a useEffect to log when the preview modal is shown/hidden + useEffect(() => { + console.log('Show preview changed:', showPreview); + if (showPreview) { + console.log('Selected receipt:', selectedReceipt); + } + }, [showPreview, selectedReceipt]); + const fetchReimbursements = async () => { setLoading(true); setError(''); @@ -241,27 +256,88 @@ export default function ReimbursementList() { const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => { try { + console.log('Previewing file for receipt ID:', receiptId); const pb = auth.getPocketBase(); + const fileManager = FileManager.getInstance(); + + // Set the selected request + setSelectedRequest(request); // Check if we already have the receipt details in our map if (receiptDetailsMap[receiptId]) { + console.log('Using cached receipt details'); // Use the cached receipt details setSelectedReceipt(receiptDetailsMap[receiptId]); - // Get the file URL using the PocketBase URL and collection info - const url = `${pb.baseUrl}/api/files/receipts/${receiptId}/${receiptDetailsMap[receiptId].file}`; + // Check if the receipt has a file + if (!receiptDetailsMap[receiptId].file) { + console.error('Receipt has no file attached'); + toast.error('This receipt has no file attached'); + setPreviewUrl(''); + setPreviewFilename(''); + setShowPreview(true); + return; + } + + // Get the file URL with token for protected files + console.log('Getting file URL with token'); + const url = await fileManager.getFileUrlWithToken( + 'receipts', + receiptId, + receiptDetailsMap[receiptId].file, + true // Use token for protected files + ); + + // Check if the URL is empty + if (!url) { + console.error('Failed to get file URL: Empty URL returned'); + toast.error('Failed to load receipt: Could not generate file URL'); + // Still show the preview modal but with empty URL to display the error message + setPreviewUrl(''); + setPreviewFilename(receiptDetailsMap[receiptId].file || ''); + setShowPreview(true); + return; + } + + console.log('Got URL:', url.substring(0, 50) + '...'); + + // Set the preview URL and filename setPreviewUrl(url); setPreviewFilename(receiptDetailsMap[receiptId].file); + + // Show the preview modal setShowPreview(true); + + // Log the current state + console.log('Current state after setting:', { + previewUrl: url, + previewFilename: receiptDetailsMap[receiptId].file, + showPreview: true + }); + return; } // If not in the map, get the receipt record using its ID + console.log('Fetching receipt details from server'); const receiptRecord = await pb.collection('receipts').getOne(receiptId, { $autoCancel: false }); if (receiptRecord) { + console.log('Receipt record found:', receiptRecord.id); + console.log('Receipt file:', receiptRecord.file); + + // Check if the receipt has a file + if (!receiptRecord.file) { + console.error('Receipt has no file attached'); + toast.error('This receipt has no file attached'); + setPreviewUrl(''); + setPreviewFilename(''); + setShowPreview(true); + return; + } + // Parse the itemized expenses if it's a string const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string' ? JSON.parse(receiptRecord.itemized_expenses) @@ -290,16 +366,51 @@ export default function ReimbursementList() { setSelectedReceipt(receiptDetails); - // Get the file URL using the PocketBase URL and collection info - const url = `${pb.baseUrl}/api/files/receipts/${receiptRecord.id}/${receiptRecord.file}`; + // Get the file URL with token for protected files + console.log('Getting file URL with token for new receipt'); + const url = await fileManager.getFileUrlWithToken( + 'receipts', + receiptRecord.id, + receiptRecord.file, + true // Use token for protected files + ); + + // Check if the URL is empty + if (!url) { + console.error('Failed to get file URL: Empty URL returned'); + toast.error('Failed to load receipt: Could not generate file URL'); + // Still show the preview modal but with empty URL to display the error message + setPreviewUrl(''); + setPreviewFilename(receiptRecord.file || ''); + setShowPreview(true); + return; + } + + console.log('Got URL:', url.substring(0, 50) + '...'); + + // Set the preview URL and filename setPreviewUrl(url); setPreviewFilename(receiptRecord.file); + + // Show the preview modal setShowPreview(true); + + // Log the current state + console.log('Current state after setting:', { + previewUrl: url, + previewFilename: receiptRecord.file, + showPreview: true + }); } else { throw new Error('Receipt not found'); } } catch (error) { console.error('Error loading receipt:', error); + toast.error('Failed to load receipt. Please try again.'); + // Show the preview modal with empty URL to display the error message + setPreviewUrl(''); + setPreviewFilename(''); + setShowPreview(true); } }; @@ -705,11 +816,25 @@ export default function ReimbursementList() {
- + {previewUrl ? ( + + ) : ( +
+
+ +
+
+

Receipt Image Not Available

+

+ The receipt image could not be loaded. This might be due to permission issues or the file may not exist. +

+
+
+ )}
diff --git a/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx b/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx index 04ea029..f100688 100644 --- a/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx +++ b/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx @@ -4,6 +4,7 @@ import FilePreview from '../universal/FilePreview'; import { Get } from '../../../scripts/pocketbase/Get'; import { Update } from '../../../scripts/pocketbase/Update'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import { FileManager } from '../../../scripts/pocketbase/FileManager'; import { toast } from 'react-hot-toast'; import { motion, AnimatePresence } from 'framer-motion'; import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase'; @@ -70,6 +71,7 @@ export default function ReimbursementManagementPortal() { const [showRejectModal, setShowRejectModal] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [rejectingId, setRejectingId] = useState(null); + const [receiptUrl, setReceiptUrl] = useState(''); useEffect(() => { const auth = Authentication.getInstance(); @@ -379,16 +381,34 @@ export default function ReimbursementManagementPortal() { } }; - const toggleReceipt = (receiptId: string) => { - setExpandedReceipts(prev => { - const next = new Set(prev); - if (next.has(receiptId)) { - next.delete(receiptId); - } else { - next.add(receiptId); + const toggleReceipt = async (receiptId: string) => { + if (expandedReceipts.has(receiptId)) { + // If already expanded, collapse it + const newSet = new Set(expandedReceipts); + newSet.delete(receiptId); + setExpandedReceipts(newSet); + setSelectedReceipt(null); + } else { + // If not expanded, expand it + const newSet = new Set(expandedReceipts); + newSet.add(receiptId); + setExpandedReceipts(newSet); + + // Set the selected receipt + const receipt = receipts[receiptId]; + if (receipt) { + setSelectedReceipt(receipt); + + // Get the receipt URL and update the state + try { + const url = await getReceiptUrl(receipt); + setReceiptUrl(url); + } catch (error) { + console.error('Error getting receipt URL:', error); + setReceiptUrl(''); + } } - return next; - }); + } }; // Update the auditReceipt function @@ -404,6 +424,15 @@ export default function ReimbursementManagementPortal() { const receipt = receipts[receiptId]; if (!receipt) throw new Error('Receipt not found'); + // Get the receipt URL and update the state + try { + const url = await getReceiptUrl(receipt); + setReceiptUrl(url); + } catch (error) { + console.error('Error getting receipt URL:', error); + setReceiptUrl(''); + } + const updatedAuditors = [...new Set([...receipt.audited_by, userId])]; await update.updateFields('receipts', receiptId, { @@ -441,6 +470,8 @@ export default function ReimbursementManagementPortal() { } })); + setSelectedReceipt(receipt); + setShowReceiptModal(true); toast.success('Receipt audited successfully'); } catch (error) { console.error('Error auditing receipt:', error); @@ -463,10 +494,15 @@ export default function ReimbursementManagementPortal() { }); }; - const getReceiptUrl = (receipt: ExtendedReceipt): string => { + const getReceiptUrl = async (receipt: ExtendedReceipt): Promise => { try { - const pb = Authentication.getInstance().getPocketBase(); - return pb.files.getURL(receipt, receipt.file); + const fileManager = FileManager.getInstance(); + return await fileManager.getFileUrlWithToken( + 'receipts', + receipt.id, + receipt.file, + true // Use token for protected files + ); } catch (error) { console.error('Error getting receipt URL:', error); return ''; @@ -1648,7 +1684,7 @@ export default function ReimbursementManagementPortal() {
diff --git a/src/components/dashboard/universal/FilePreview.tsx b/src/components/dashboard/universal/FilePreview.tsx index 8f5fafd..135e7ae 100644 --- a/src/components/dashboard/universal/FilePreview.tsx +++ b/src/components/dashboard/universal/FilePreview.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { Icon } from "@iconify/react"; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; +import { Authentication } from '../../../scripts/pocketbase/Authentication'; // Cache for file content const contentCache = new Map(); @@ -36,6 +37,19 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil return `${truncatedName}...${extension ? `.${extension}` : ''}`; }, [filename]); + // Update URL and filename when props change + useEffect(() => { + console.log('FilePreview props changed:', { initialUrl, initialFilename }); + if (initialUrl !== url) { + console.log('URL changed from props:', initialUrl); + setUrl(initialUrl); + } + if (initialFilename !== filename) { + console.log('Filename changed from props:', initialFilename); + setFilename(initialFilename); + } + }, [initialUrl, initialFilename, url, filename]); + // Intersection Observer callback useEffect(() => { const observer = new IntersectionObserver( @@ -55,7 +69,20 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil }, []); const loadContent = useCallback(async () => { - if (!url || !filename) return; + if (!url) { + // Don't log a warning if URL is empty during initial component mount + // This is a normal state before the URL is set + setError('No file URL provided'); + setLoading(false); + return; + } + + if (!filename) { + console.warn('Cannot load content: Filename is empty'); + setError('No filename provided'); + setLoading(false); + return; + } console.log('Loading content for:', { url, filename }); setLoading(true); @@ -72,9 +99,61 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil return; } + // Check if it's likely an image based on filename extension + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; + const fileExtension = filename.split('.').pop()?.toLowerCase() || ''; + const isProbablyImage = imageExtensions.includes(fileExtension); + + if (isProbablyImage) { + // Try loading as an image first to bypass CORS issues + try { + console.log('Trying to load as image:', url); + const img = new Image(); + img.crossOrigin = 'anonymous'; // Try anonymous mode first + + // Create a promise to handle image loading + const imageLoaded = new Promise((resolve, reject) => { + img.onload = () => resolve('image'); + img.onerror = (e) => reject(new Error('Failed to load image')); + }); + + img.src = url; + + // Wait for image to load + await imageLoaded; + + // If we get here, image loaded successfully + setContent('image'); + setFileType('image/' + fileExtension); + + // Cache the content + contentCache.set(cacheKey, { + content: 'image', + fileType: 'image/' + fileExtension, + timestamp: Date.now() + }); + + setLoading(false); + return; + } catch (imgError) { + console.warn('Failed to load as image, falling back to fetch:', imgError); + // Continue to fetch method + } + } + try { - console.log('Fetching file...'); - const response = await fetch(url); + console.log('Fetching file from URL:', url); + const response = await fetch(url, { + headers: { + 'Cache-Control': 'no-cache', // Bypass cache + } + }); + + if (!response.ok) { + console.error('File fetch failed with status:', response.status, response.statusText); + throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); + } + const contentType = response.headers.get('content-type'); console.log('Received content type:', contentType); setFileType(contentType); @@ -112,13 +191,15 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil }, [url, filename]); useEffect(() => { - if (isVisible || !isModal) { // Load content immediately if not in modal + // Only attempt to load content if URL is not empty + if ((isVisible || !isModal) && url) { loadContent(); } - }, [isVisible, loadContent, isModal]); + }, [isVisible, loadContent, isModal, url]); useEffect(() => { - console.log('FilePreview component mounted'); + console.log('FilePreview component mounted or updated with URL:', url); + console.log('Filename:', filename); if (isModal) { const handleStateChange = (event: CustomEvent<{ url: string; filename: string }>) => { @@ -145,6 +226,14 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil } }, [isModal, initialUrl, initialFilename]); + // Add a new effect to handle URL changes + useEffect(() => { + if (url && isVisible) { + console.log('URL changed, loading content:', url); + loadContent(); + } + }, [url, isVisible, loadContent]); + const handleDownload = async () => { try { const response = await fetch(url); @@ -250,44 +339,26 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil } try { - return hljs.highlight(code, { language }).value; + // Use highlight.js to highlight the code + const highlighted = hljs.highlight(code, { language }).value; + return highlighted; } catch (error) { - console.warn(`Failed to highlight code for language ${language}:`, error); - return code; + console.warn(`Failed to highlight code as ${language}, falling back to plaintext`); + return hljs.highlight(code, { language: 'plaintext' }).value; } }, []); const formatCodeWithLineNumbers = useCallback((code: string, language: string) => { - // Special handling for CSV files - if (language === 'csv') { - return renderCSVTable(code); - } + const highlighted = highlightCode(code, language); + const lines = highlighted.split('\n').slice(0, visibleLines); - const lines = code.split('\n'); - const totalLines = lines.length; - const linesToShow = Math.min(visibleLines, totalLines); - - let formattedCode = lines - .slice(0, linesToShow) - .map((line, index) => { - const lineNumber = index + 1; - const highlightedLine = highlightCode(line, language); - return `
-
${lineNumber}
-
${highlightedLine || ' '}
-
`; - }) - .join(''); - - if (linesToShow < totalLines) { - formattedCode += `
-
-
... ${totalLines - linesToShow} more lines
-
`; - } - - return formattedCode; - }, [highlightCode, visibleLines, renderCSVTable]); + return lines.map((line, i) => + `
+
${i + 1}
+
${line || ' '}
+
` + ).join(''); + }, [visibleLines, highlightCode]); const handleShowMore = useCallback(() => { setVisibleLines(prev => Math.min(prev + CHUNK_SIZE, content?.split('\n').length || 0)); @@ -297,84 +368,104 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil setVisibleLines(INITIAL_LINES_TO_SHOW); }, []); + // If URL is empty, show a message + if (!url) { + return ( +
+
+ +

No File URL Provided

+

Please check if the file exists or if you have the necessary permissions.

+
+
+ ); + } + return (
- {!loading && !error && content === 'image' && ( -
-
-
- {truncatedFilename} - {fileType && ( - {fileType.split('/')[1]} - )} +
+
+ {truncatedFilename} + {fileType && ( + {fileType.split('/')[1]} + )} +
+ +
+ +
+ {loading && ( +
+ +
+ )} + + {error && ( +
+
+ +
+
+

Preview Unavailable

+

{error}

+
+ )} + + {!loading && !error && content === 'image' && (
{filename} { + console.error('Image failed to load:', e); + setError('Failed to load image. This might be due to permission issues or the file may not exist.'); + + // Log additional details + console.log('Image URL that failed:', url); + console.log('Current auth status:', + Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated' + ); + }} />
-
- )} + )} - {!loading && !error && content === 'video' && ( -
-
-
- {truncatedFilename} - {fileType && ( - {fileType.split('/')[1]} - )} -
- -
+ {!loading && !error && content === 'video' && (
-
- )} + )} - {!loading && !error && content === 'pdf' && ( -
-
-
- {truncatedFilename} - {fileType && ( - {fileType.split('/')[1]} - )} -
- -
+ {!loading && !error && content === 'pdf' && (
-
- )} + )} - {!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && ( -
-
-
- {truncatedFilename} - {fileType && ( - {fileType.split('/')[1]} - )} -
-
- {content && content.split('\n').length > visibleLines && ( - - )} - {visibleLines > INITIAL_LINES_TO_SHOW && ( - - )} - -
-
+ {!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
-
+ {filename.toLowerCase().endsWith('.csv') ? ( +
+ ) : ( +
+                                    
+                                
+ )}
-
- )} - - {loading && ( -
- -
- )} - - {error && ( -
-
- -
-
-

Preview Unavailable

-

{error}

-
- -
- )} + )} +
); } \ No newline at end of file diff --git a/src/scripts/pocketbase/FileManager.ts b/src/scripts/pocketbase/FileManager.ts index 9ee4a4e..eb4524c 100644 --- a/src/scripts/pocketbase/FileManager.ts +++ b/src/scripts/pocketbase/FileManager.ts @@ -30,7 +30,7 @@ export class FileManager { collectionName: string, recordId: string, field: string, - file: File + file: File, ): Promise { if (!this.auth.isAuthenticated()) { throw new Error("User must be authenticated to upload files"); @@ -42,7 +42,9 @@ export class FileManager { const formData = new FormData(); formData.append(field, file); - const result = await pb.collection(collectionName).update(recordId, formData); + const result = await pb + .collection(collectionName) + .update(recordId, formData); return result; } catch (err) { console.error(`Failed to upload file to ${collectionName}:`, err); @@ -64,7 +66,7 @@ export class FileManager { collectionName: string, recordId: string, field: string, - files: File[] + files: File[], ): Promise { if (!this.auth.isAuthenticated()) { throw new Error("User must be authenticated to upload files"); @@ -73,14 +75,16 @@ export class FileManager { try { this.auth.setUpdating(true); const pb = this.auth.getPocketBase(); - + const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit const MAX_BATCH_SIZE = 25 * 1024 * 1024; // 25MB per batch - + // Validate file sizes first for (const file of files) { if (file.size > MAX_FILE_SIZE) { - throw new Error(`File ${file.name} is too large. Maximum size is 50MB.`); + throw new Error( + `File ${file.name} is too large. Maximum size is 50MB.`, + ); } } @@ -88,10 +92,12 @@ export class FileManager { let existingFiles: string[] = []; if (recordId) { try { - const record = await pb.collection(collectionName).getOne(recordId); + const record = await pb + .collection(collectionName) + .getOne(recordId); existingFiles = (record as any)[field] || []; } catch (error) { - console.warn('Failed to fetch existing record:', error); + console.warn("Failed to fetch existing record:", error); } } @@ -103,10 +109,10 @@ export class FileManager { // Process each file for (const file of files) { let processedFile = file; - + try { // Try to compress image files if needed - if (file.type.startsWith('image/')) { + if (file.type.startsWith("image/")) { processedFile = await this.compressImageIfNeeded(file, 50); // 50MB max size } } catch (error) { @@ -118,7 +124,12 @@ export class FileManager { if (currentBatchSize + processedFile.size > MAX_BATCH_SIZE) { // Upload current batch if (currentBatch.length > 0) { - await this.uploadBatch(collectionName, recordId, field, currentBatch); + await this.uploadBatch( + collectionName, + recordId, + field, + currentBatch, + ); allProcessedFiles.push(...currentBatch); } // Reset batch @@ -138,7 +149,9 @@ export class FileManager { } // Get the final record state - const finalRecord = await pb.collection(collectionName).getOne(recordId); + const finalRecord = await pb + .collection(collectionName) + .getOne(recordId); return finalRecord; } catch (err) { console.error(`Failed to upload files to ${collectionName}:`, err); @@ -156,7 +169,7 @@ export class FileManager { collectionName: string, recordId: string, field: string, - files: File[] + files: File[], ): Promise { const pb = this.auth.getPocketBase(); const formData = new FormData(); @@ -170,7 +183,9 @@ export class FileManager { await pb.collection(collectionName).update(recordId, formData); } catch (error: any) { if (error.status === 413) { - throw new Error(`Upload failed: Batch size too large. Please try uploading smaller files.`); + throw new Error( + `Upload failed: Batch size too large. Please try uploading smaller files.`, + ); } throw error; } @@ -188,7 +203,7 @@ export class FileManager { collectionName: string, recordId: string, field: string, - files: File[] + files: File[], ): Promise { if (!this.auth.isAuthenticated()) { throw new Error("User must be authenticated to upload files"); @@ -197,10 +212,10 @@ export class FileManager { try { this.auth.setUpdating(true); const pb = this.auth.getPocketBase(); - + // First, get the current record to check existing files const record = await pb.collection(collectionName).getOne(recordId); - + // Create FormData with existing files const formData = new FormData(); @@ -210,7 +225,9 @@ export class FileManager { // For each existing file, we need to fetch it and add it to the FormData for (const existingFile of existingFiles) { try { - const response = await fetch(this.getFileUrl(collectionName, recordId, existingFile)); + const response = await fetch( + this.getFileUrl(collectionName, recordId, existingFile), + ); const blob = await response.blob(); const file = new File([blob], existingFile, { type: blob.type }); formData.append(field, file); @@ -218,13 +235,15 @@ export class FileManager { console.warn(`Failed to fetch existing file ${existingFile}:`, error); } } - + // Append new files - files.forEach(file => { + files.forEach((file) => { formData.append(field, file); }); - const result = await pb.collection(collectionName).update(recordId, formData); + const result = await pb + .collection(collectionName) + .update(recordId, formData); return result; } catch (err) { console.error(`Failed to append files to ${collectionName}:`, err); @@ -244,12 +263,13 @@ export class FileManager { public getFileUrl( collectionName: string, recordId: string, - filename: string + filename: string, ): string { const pb = this.auth.getPocketBase(); - const token = pb.authStore.token; - const url = `${pb.baseUrl}/api/files/${collectionName}/${recordId}/${filename}`; - return url; + return pb.files.getURL( + { id: recordId, collectionId: collectionName }, + filename, + ); } /** @@ -262,7 +282,7 @@ export class FileManager { public async deleteFile( collectionName: string, recordId: string, - field: string + field: string, ): Promise { if (!this.auth.isAuthenticated()) { throw new Error("User must be authenticated to delete files"); @@ -272,7 +292,9 @@ export class FileManager { this.auth.setUpdating(true); const pb = this.auth.getPocketBase(); const data = { [field]: null }; - const result = await pb.collection(collectionName).update(recordId, data); + const result = await pb + .collection(collectionName) + .update(recordId, data); return result; } catch (err) { console.error(`Failed to delete file from ${collectionName}:`, err); @@ -292,7 +314,7 @@ export class FileManager { public async downloadFile( collectionName: string, recordId: string, - filename: string + filename: string, ): Promise { if (!this.auth.isAuthenticated()) { throw new Error("User must be authenticated to download files"); @@ -302,11 +324,11 @@ export class FileManager { this.auth.setUpdating(true); const url = this.getFileUrl(collectionName, recordId, filename); const response = await fetch(url); - + if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - + const result = await response.blob(); return result; } catch (err) { @@ -327,7 +349,7 @@ export class FileManager { public async getFiles( collectionName: string, recordId: string, - field: string + field: string, ): Promise { if (!this.auth.isAuthenticated()) { throw new Error("User must be authenticated to get files"); @@ -336,18 +358,18 @@ export class FileManager { try { this.auth.setUpdating(true); const pb = this.auth.getPocketBase(); - + // Get the record to retrieve the filenames const record = await pb.collection(collectionName).getOne(recordId); - + // Get the filenames from the specified field const filenames = record[field] || []; - + // Convert filenames to URLs - const fileUrls = filenames.map((filename: string) => - this.getFileUrl(collectionName, recordId, filename) + const fileUrls = filenames.map((filename: string) => + this.getFileUrl(collectionName, recordId, filename), ); - + return fileUrls; } catch (err) { console.error(`Failed to get files from ${collectionName}:`, err); @@ -363,8 +385,11 @@ export class FileManager { * @param maxSizeInMB Maximum size in MB * @returns Promise The compressed file */ - public async compressImageIfNeeded(file: File, maxSizeInMB: number = 50): Promise { - if (!file.type.startsWith('image/')) { + public async compressImageIfNeeded( + file: File, + maxSizeInMB: number = 50, + ): Promise { + if (!file.type.startsWith("image/")) { return file; } @@ -379,12 +404,12 @@ export class FileManager { reader.onload = (e) => { const img = new Image(); img.src = e.target?.result as string; - + img.onload = () => { - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); let width = img.width; let height = img.height; - + // Calculate new dimensions while maintaining aspect ratio const maxDimension = 3840; // Higher quality for larger files if (width > height && width > maxDimension) { @@ -397,35 +422,155 @@ export class FileManager { canvas.width = width; canvas.height = height; - - const ctx = canvas.getContext('2d'); + + const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0, width, height); - + // Convert to blob with higher quality for larger files canvas.toBlob( (blob) => { if (!blob) { - reject(new Error('Failed to compress image')); + reject(new Error("Failed to compress image")); return; } - resolve(new File([blob], file.name, { - type: 'image/jpeg', - lastModified: Date.now(), - })); + resolve( + new File([blob], file.name, { + type: "image/jpeg", + lastModified: Date.now(), + }), + ); }, - 'image/jpeg', - 0.85 // Higher quality setting for larger files + "image/jpeg", + 0.85, // Higher quality setting for larger files ); }; - + img.onerror = () => { - reject(new Error('Failed to load image for compression')); + reject(new Error("Failed to load image for compression")); }; }; - + reader.onerror = () => { - reject(new Error('Failed to read file for compression')); + reject(new Error("Failed to read file for compression")); }; }); } -} \ No newline at end of file + + /** + * Get a file token for accessing protected files + * @returns Promise The file token + */ + public async getFileToken(): Promise { + // Check authentication status + if (!this.auth.isAuthenticated()) { + console.warn("User is not authenticated when trying to get file token"); + + // Try to refresh the auth if possible + try { + const pb = this.auth.getPocketBase(); + if (pb.authStore.isValid) { + console.log( + "Auth store is valid, but auth check failed. Trying to refresh token.", + ); + await pb.collection("users").authRefresh(); + console.log("Auth refreshed successfully"); + } else { + throw new Error("User must be authenticated to get a file token"); + } + } catch (refreshError) { + console.error("Failed to refresh authentication:", refreshError); + throw new Error("User must be authenticated to get a file token"); + } + } + + try { + this.auth.setUpdating(true); + const pb = this.auth.getPocketBase(); + + // Log auth status + console.log("Auth status before getting token:", { + isValid: pb.authStore.isValid, + token: pb.authStore.token + ? pb.authStore.token.substring(0, 10) + "..." + : "none", + model: pb.authStore.model ? pb.authStore.model.id : "none", + }); + + const result = await pb.files.getToken(); + console.log("Got file token:", result.substring(0, 10) + "..."); + return result; + } catch (err) { + console.error("Failed to get file token:", err); + throw err; + } finally { + this.auth.setUpdating(false); + } + } + + /** + * Get a file URL with an optional token for protected files + * @param collectionName The name of the collection + * @param recordId The ID of the record containing the file + * @param filename The name of the file + * @param useToken Whether to include a token for protected files + * @returns Promise The file URL with token if requested + */ + public async getFileUrlWithToken( + collectionName: string, + recordId: string, + filename: string, + useToken: boolean = false, + ): Promise { + const pb = this.auth.getPocketBase(); + + // Check if filename is empty + if (!filename) { + console.error( + `Empty filename provided for ${collectionName}/${recordId}`, + ); + return ""; + } + + // Check if user is authenticated + if (!this.auth.isAuthenticated()) { + console.warn("User is not authenticated when trying to get file URL"); + } + + // Always try to use token for protected files + if (useToken) { + try { + console.log( + `Getting file token for ${collectionName}/${recordId}/${filename}`, + ); + const token = await this.getFileToken(); + console.log(`Got token: ${token.substring(0, 10)}...`); + + // Make sure to pass the token as a query parameter + const url = pb.files.getURL( + { id: recordId, collectionId: collectionName }, + filename, + { token }, + ); + console.log(`Generated URL with token: ${url.substring(0, 50)}...`); + return url; + } catch (error) { + console.error("Error getting file token:", error); + // Fall back to URL without token + const url = pb.files.getURL( + { id: recordId, collectionId: collectionName }, + filename, + ); + console.log(`Fallback URL without token: ${url.substring(0, 50)}...`); + return url; + } + } + + // If not using token + const url = pb.files.getURL( + { id: recordId, collectionId: collectionName }, + filename, + ); + console.log(`Generated URL without token: ${url.substring(0, 50)}...`); + return url; + } +}