file component update

This commit is contained in:
chark1es 2025-03-03 02:10:48 -08:00
parent 36e1f4663b
commit 19f360e3f0
5 changed files with 591 additions and 238 deletions

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"workbench.colorCustomizations": {
"activityBar.background": "#221489",
"titleBar.activeBackground": "#301DC0",
"titleBar.activeForeground": "#F9F9FE"
}
}

View file

@ -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() {
</motion.a>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
{previewUrl ? (
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
) : (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Receipt Image Not Available</h3>
<p className="text-base-content/70 max-w-md">
The receipt image could not be loaded. This might be due to permission issues or the file may not exist.
</p>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -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<string | null>(null);
const [receiptUrl, setReceiptUrl] = useState<string>('');
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<string> => {
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() {
</div>
<div className="px-4 py-0">
<FilePreview
url={getReceiptUrl(selectedReceipt)}
url={receiptUrl}
filename={`Receipt from ${selectedReceipt.location_name}`}
/>
</div>

View file

@ -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<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>();
@ -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 `<div class="table-row ">
<div class="table-cell text-right pr-4 select-none text-base-content/50 text-sm border-r border-base-content/10">${lineNumber}</div>
<div class="table-cell pl-4 whitespace-pre">${highlightedLine || ' '}</div>
</div>`;
})
.join('');
if (linesToShow < totalLines) {
formattedCode += `<div class="table-row ">
<div class="table-cell"></div>
<div class="table-cell pl-4 pt-2 text-base-content/70">... ${totalLines - linesToShow} more lines</div>
</div>`;
}
return formattedCode;
}, [highlightCode, visibleLines, renderCSVTable]);
return lines.map((line, i) =>
`<div class="table-row">
<div class="table-cell text-right pr-4 select-none opacity-50 w-12">${i + 1}</div>
<div class="table-cell">${line || ' '}</div>
</div>`
).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 (
<div className="file-preview-container bg-base-100 rounded-lg shadow-md overflow-hidden">
<div className="p-6 flex flex-col items-center justify-center text-center">
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning mb-3" />
<h3 className="text-lg font-semibold mb-2">No File URL Provided</h3>
<p className="text-base-content/70">Please check if the file exists or if you have the necessary permissions.</p>
</div>
</div>
);
}
return (
<div className="file-preview-container space-y-4">
{!loading && !error && content === 'image' && (
<div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download
</button>
</div>
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
{loading && (
<div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center p-8 bg-base-200 rounded-lg text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<Icon icon="mdi:alert" className="h-12 w-12 text-warning" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Preview Unavailable</h3>
<p className="text-base-content/70 max-w-md">{error}</p>
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
className="btn btn-warning btn-sm gap-2 mt-4"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download
Download File Instead
</button>
<button
className="btn btn-sm btn-outline btn-error mt-4"
onClick={loadContent}
>
Try Again
</button>
</div>
)}
{!loading && !error && content === 'image' && (
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<img
src={url}
alt={filename}
className="max-w-full h-auto rounded-lg"
loading="lazy"
onError={(e) => {
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'
);
}}
/>
</div>
</div>
)}
)}
{!loading && !error && content === 'video' && (
<div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download
</button>
</div>
{!loading && !error && content === 'video' && (
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<video
controls
className="max-w-full rounded-lg"
style={{ maxHeight: '600px' }}
className="max-w-full h-auto rounded-lg"
preload="metadata"
>
<source src={url} type="video/mp4" />
<source src={url} type={fileType || 'video/mp4'} />
Your browser does not support the video tag.
</video>
</div>
</div>
)}
)}
{!loading && !error && content === 'pdf' && (
<div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download
</button>
</div>
{!loading && !error && content === 'pdf' && (
<div className="w-full h-[600px] bg-base-200 p-4 rounded-b-lg">
<iframe
src={url}
@ -383,81 +474,30 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
loading="lazy"
></iframe>
</div>
</div>
)}
)}
{!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
<div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<div className="flex items-center gap-2">
{content && content.split('\n').length > visibleLines && (
<button
onClick={handleShowMore}
className="btn btn-sm btn-ghost"
>
Show More
</button>
)}
{visibleLines > INITIAL_LINES_TO_SHOW && (
<button
onClick={handleShowLess}
className="btn btn-sm btn-ghost"
>
Show Less
</button>
)}
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download
</button>
</div>
</div>
{!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
<div className="overflow-x-auto max-h-[600px] bg-base-200">
<div className={`p-1 ${filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
<div
className={filename.toLowerCase().endsWith('.csv') ? '' : 'hljs table w-full font-mono text-sm rounded-lg py-4 px-2'}
dangerouslySetInnerHTML={{
__html: formatCodeWithLineNumbers(content, getLanguageFromFilename(filename))
}}
/>
{filename.toLowerCase().endsWith('.csv') ? (
<div dangerouslySetInnerHTML={{ __html: renderCSVTable(content) }} />
) : (
<pre className="text-sm">
<code
className={`language-${getLanguageFromFilename(filename)}`}
dangerouslySetInnerHTML={{
__html: formatCodeWithLineNumbers(
content.split('\n').slice(0, visibleLines).join('\n'),
getLanguageFromFilename(filename)
)
}}
/>
</pre>
)}
</div>
</div>
</div>
)}
{loading && (
<div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center p-8 bg-base-200 rounded-lg text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<Icon icon="mdi:alert" className="h-12 w-12 text-warning" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Preview Unavailable</h3>
<p className="text-base-content/70 max-w-md">{error}</p>
</div>
<button
onClick={handleDownload}
className="btn btn-warning btn-sm gap-2 mt-4"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download File Instead
</button>
</div>
)}
)}
</div>
</div>
);
}

View file

@ -30,7 +30,7 @@ export class FileManager {
collectionName: string,
recordId: string,
field: string,
file: File
file: File,
): Promise<T> {
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<T>(recordId, formData);
const result = await pb
.collection(collectionName)
.update<T>(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<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to upload files");
@ -80,7 +82,9 @@ export class FileManager {
// 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<T>(recordId);
const record = await pb
.collection(collectionName)
.getOne<T>(recordId);
existingFiles = (record as any)[field] || [];
} catch (error) {
console.warn('Failed to fetch existing record:', error);
console.warn("Failed to fetch existing record:", error);
}
}
@ -106,7 +112,7 @@ export class FileManager {
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<T>(recordId);
const finalRecord = await pb
.collection(collectionName)
.getOne<T>(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<void> {
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<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to upload files");
@ -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);
@ -220,11 +237,13 @@ export class FileManager {
}
// Append new files
files.forEach(file => {
files.forEach((file) => {
formData.append(field, file);
});
const result = await pb.collection(collectionName).update<T>(recordId, formData);
const result = await pb
.collection(collectionName)
.update<T>(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<T = any>(
collectionName: string,
recordId: string,
field: string
field: string,
): Promise<T> {
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<T>(recordId, data);
const result = await pb
.collection(collectionName)
.update<T>(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<Blob> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to download files");
@ -327,7 +349,7 @@ export class FileManager {
public async getFiles(
collectionName: string,
recordId: string,
field: string
field: string,
): Promise<string[]> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to get files");
@ -345,7 +367,7 @@ export class FileManager {
// Convert filenames to URLs
const fileUrls = filenames.map((filename: string) =>
this.getFileUrl(collectionName, recordId, filename)
this.getFileUrl(collectionName, recordId, filename),
);
return fileUrls;
@ -363,8 +385,11 @@ export class FileManager {
* @param maxSizeInMB Maximum size in MB
* @returns Promise<File> The compressed file
*/
public async compressImageIfNeeded(file: File, maxSizeInMB: number = 50): Promise<File> {
if (!file.type.startsWith('image/')) {
public async compressImageIfNeeded(
file: File,
maxSizeInMB: number = 50,
): Promise<File> {
if (!file.type.startsWith("image/")) {
return file;
}
@ -381,7 +406,7 @@ export class FileManager {
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;
@ -398,34 +423,154 @@ 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"));
};
});
}
/**
* Get a file token for accessing protected files
* @returns Promise<string> The file token
*/
public async getFileToken(): Promise<string> {
// 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<string> The file URL with token if requested
*/
public async getFileUrlWithToken(
collectionName: string,
recordId: string,
filename: string,
useToken: boolean = false,
): Promise<string> {
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;
}
}