file component update
This commit is contained in:
parent
36e1f4663b
commit
19f360e3f0
5 changed files with 591 additions and 238 deletions
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"activityBar.background": "#221489",
|
||||||
|
"titleBar.activeBackground": "#301DC0",
|
||||||
|
"titleBar.activeForeground": "#F9F9FE"
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
interface AuditNote {
|
interface AuditNote {
|
||||||
note: string;
|
note: string;
|
||||||
|
@ -121,6 +122,20 @@ export default function ReimbursementList() {
|
||||||
console.log('Number of requests:', requests.length);
|
console.log('Number of requests:', requests.length);
|
||||||
}, [requests]);
|
}, [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 () => {
|
const fetchReimbursements = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
@ -241,27 +256,88 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
|
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('Previewing file for receipt ID:', receiptId);
|
||||||
const pb = auth.getPocketBase();
|
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
|
// Check if we already have the receipt details in our map
|
||||||
if (receiptDetailsMap[receiptId]) {
|
if (receiptDetailsMap[receiptId]) {
|
||||||
|
console.log('Using cached receipt details');
|
||||||
// Use the cached receipt details
|
// Use the cached receipt details
|
||||||
setSelectedReceipt(receiptDetailsMap[receiptId]);
|
setSelectedReceipt(receiptDetailsMap[receiptId]);
|
||||||
|
|
||||||
// Get the file URL using the PocketBase URL and collection info
|
// Check if the receipt has a file
|
||||||
const url = `${pb.baseUrl}/api/files/receipts/${receiptId}/${receiptDetailsMap[receiptId].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);
|
setPreviewUrl(url);
|
||||||
setPreviewFilename(receiptDetailsMap[receiptId].file);
|
setPreviewFilename(receiptDetailsMap[receiptId].file);
|
||||||
|
|
||||||
|
// Show the preview modal
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
|
|
||||||
|
// Log the current state
|
||||||
|
console.log('Current state after setting:', {
|
||||||
|
previewUrl: url,
|
||||||
|
previewFilename: receiptDetailsMap[receiptId].file,
|
||||||
|
showPreview: true
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in the map, get the receipt record using its ID
|
// 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, {
|
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
|
||||||
$autoCancel: false
|
$autoCancel: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (receiptRecord) {
|
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
|
// Parse the itemized expenses if it's a string
|
||||||
const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string'
|
const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string'
|
||||||
? JSON.parse(receiptRecord.itemized_expenses)
|
? JSON.parse(receiptRecord.itemized_expenses)
|
||||||
|
@ -290,16 +366,51 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
setSelectedReceipt(receiptDetails);
|
setSelectedReceipt(receiptDetails);
|
||||||
|
|
||||||
// Get the file URL using the PocketBase URL and collection info
|
// Get the file URL with token for protected files
|
||||||
const url = `${pb.baseUrl}/api/files/receipts/${receiptRecord.id}/${receiptRecord.file}`;
|
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);
|
setPreviewUrl(url);
|
||||||
setPreviewFilename(receiptRecord.file);
|
setPreviewFilename(receiptRecord.file);
|
||||||
|
|
||||||
|
// Show the preview modal
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
|
|
||||||
|
// Log the current state
|
||||||
|
console.log('Current state after setting:', {
|
||||||
|
previewUrl: url,
|
||||||
|
previewFilename: receiptRecord.file,
|
||||||
|
showPreview: true
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Receipt not found');
|
throw new Error('Receipt not found');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading receipt:', 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>
|
</motion.a>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
|
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
|
||||||
<FilePreview
|
{previewUrl ? (
|
||||||
url={previewUrl}
|
<FilePreview
|
||||||
filename={previewFilename}
|
url={previewUrl}
|
||||||
isModal={false}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import FilePreview from '../universal/FilePreview';
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
||||||
|
@ -70,6 +71,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||||
|
const [receiptUrl, setReceiptUrl] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -379,16 +381,34 @@ export default function ReimbursementManagementPortal() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleReceipt = (receiptId: string) => {
|
const toggleReceipt = async (receiptId: string) => {
|
||||||
setExpandedReceipts(prev => {
|
if (expandedReceipts.has(receiptId)) {
|
||||||
const next = new Set(prev);
|
// If already expanded, collapse it
|
||||||
if (next.has(receiptId)) {
|
const newSet = new Set(expandedReceipts);
|
||||||
next.delete(receiptId);
|
newSet.delete(receiptId);
|
||||||
} else {
|
setExpandedReceipts(newSet);
|
||||||
next.add(receiptId);
|
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
|
// Update the auditReceipt function
|
||||||
|
@ -404,6 +424,15 @@ export default function ReimbursementManagementPortal() {
|
||||||
const receipt = receipts[receiptId];
|
const receipt = receipts[receiptId];
|
||||||
if (!receipt) throw new Error('Receipt not found');
|
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])];
|
const updatedAuditors = [...new Set([...receipt.audited_by, userId])];
|
||||||
|
|
||||||
await update.updateFields('receipts', receiptId, {
|
await update.updateFields('receipts', receiptId, {
|
||||||
|
@ -441,6 +470,8 @@ export default function ReimbursementManagementPortal() {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
setSelectedReceipt(receipt);
|
||||||
|
setShowReceiptModal(true);
|
||||||
toast.success('Receipt audited successfully');
|
toast.success('Receipt audited successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error auditing receipt:', 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 {
|
try {
|
||||||
const pb = Authentication.getInstance().getPocketBase();
|
const fileManager = FileManager.getInstance();
|
||||||
return pb.files.getURL(receipt, receipt.file);
|
return await fileManager.getFileUrlWithToken(
|
||||||
|
'receipts',
|
||||||
|
receipt.id,
|
||||||
|
receipt.file,
|
||||||
|
true // Use token for protected files
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting receipt URL:', error);
|
console.error('Error getting receipt URL:', error);
|
||||||
return '';
|
return '';
|
||||||
|
@ -1648,7 +1684,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-0">
|
<div className="px-4 py-0">
|
||||||
<FilePreview
|
<FilePreview
|
||||||
url={getReceiptUrl(selectedReceipt)}
|
url={receiptUrl}
|
||||||
filename={`Receipt from ${selectedReceipt.location_name}`}
|
filename={`Receipt from ${selectedReceipt.location_name}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import 'highlight.js/styles/github-dark.css';
|
import 'highlight.js/styles/github-dark.css';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
// Cache for file content
|
// Cache for file content
|
||||||
const contentCache = new Map<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>();
|
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}` : ''}`;
|
return `${truncatedName}...${extension ? `.${extension}` : ''}`;
|
||||||
}, [filename]);
|
}, [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
|
// Intersection Observer callback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
|
@ -55,7 +69,20 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadContent = useCallback(async () => {
|
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 });
|
console.log('Loading content for:', { url, filename });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
@ -72,9 +99,61 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
return;
|
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 {
|
try {
|
||||||
console.log('Fetching file...');
|
console.log('Fetching file from URL:', url);
|
||||||
const response = await fetch(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');
|
const contentType = response.headers.get('content-type');
|
||||||
console.log('Received content type:', contentType);
|
console.log('Received content type:', contentType);
|
||||||
setFileType(contentType);
|
setFileType(contentType);
|
||||||
|
@ -112,13 +191,15 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
}, [url, filename]);
|
}, [url, filename]);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
loadContent();
|
||||||
}
|
}
|
||||||
}, [isVisible, loadContent, isModal]);
|
}, [isVisible, loadContent, isModal, url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('FilePreview component mounted');
|
console.log('FilePreview component mounted or updated with URL:', url);
|
||||||
|
console.log('Filename:', filename);
|
||||||
|
|
||||||
if (isModal) {
|
if (isModal) {
|
||||||
const handleStateChange = (event: CustomEvent<{ url: string; filename: string }>) => {
|
const handleStateChange = (event: CustomEvent<{ url: string; filename: string }>) => {
|
||||||
|
@ -145,6 +226,14 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
}
|
}
|
||||||
}, [isModal, initialUrl, initialFilename]);
|
}, [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 () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
@ -250,44 +339,26 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.warn(`Failed to highlight code for language ${language}:`, error);
|
console.warn(`Failed to highlight code as ${language}, falling back to plaintext`);
|
||||||
return code;
|
return hljs.highlight(code, { language: 'plaintext' }).value;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatCodeWithLineNumbers = useCallback((code: string, language: string) => {
|
const formatCodeWithLineNumbers = useCallback((code: string, language: string) => {
|
||||||
// Special handling for CSV files
|
const highlighted = highlightCode(code, language);
|
||||||
if (language === 'csv') {
|
const lines = highlighted.split('\n').slice(0, visibleLines);
|
||||||
return renderCSVTable(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = code.split('\n');
|
return lines.map((line, i) =>
|
||||||
const totalLines = lines.length;
|
`<div class="table-row">
|
||||||
const linesToShow = Math.min(visibleLines, totalLines);
|
<div class="table-cell text-right pr-4 select-none opacity-50 w-12">${i + 1}</div>
|
||||||
|
<div class="table-cell">${line || ' '}</div>
|
||||||
let formattedCode = lines
|
</div>`
|
||||||
.slice(0, linesToShow)
|
).join('');
|
||||||
.map((line, index) => {
|
}, [visibleLines, highlightCode]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleShowMore = useCallback(() => {
|
const handleShowMore = useCallback(() => {
|
||||||
setVisibleLines(prev => Math.min(prev + CHUNK_SIZE, content?.split('\n').length || 0));
|
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);
|
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 (
|
return (
|
||||||
<div className="file-preview-container space-y-4">
|
<div className="file-preview-container space-y-4">
|
||||||
{!loading && !error && content === 'image' && (
|
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
|
||||||
<div>
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
|
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
{fileType && (
|
||||||
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
|
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && content === 'image' && (
|
||||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={filename}
|
alt={filename}
|
||||||
className="max-w-full h-auto rounded-lg"
|
className="max-w-full h-auto rounded-lg"
|
||||||
loading="lazy"
|
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>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && content === 'video' && (
|
{!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>
|
|
||||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
className="max-w-full rounded-lg"
|
className="max-w-full h-auto rounded-lg"
|
||||||
style={{ maxHeight: '600px' }}
|
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
>
|
>
|
||||||
<source src={url} type="video/mp4" />
|
<source src={url} type={fileType || 'video/mp4'} />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && content === 'pdf' && (
|
{!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>
|
|
||||||
<div className="w-full h-[600px] bg-base-200 p-4 rounded-b-lg">
|
<div className="w-full h-[600px] bg-base-200 p-4 rounded-b-lg">
|
||||||
<iframe
|
<iframe
|
||||||
src={url}
|
src={url}
|
||||||
|
@ -383,81 +474,30 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
|
{!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>
|
|
||||||
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
||||||
<div className={`p-1 ${filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
<div className={`p-1 ${filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
||||||
<div
|
{filename.toLowerCase().endsWith('.csv') ? (
|
||||||
className={filename.toLowerCase().endsWith('.csv') ? '' : 'hljs table w-full font-mono text-sm rounded-lg py-4 px-2'}
|
<div dangerouslySetInnerHTML={{ __html: renderCSVTable(content) }} />
|
||||||
dangerouslySetInnerHTML={{
|
) : (
|
||||||
__html: formatCodeWithLineNumbers(content, getLanguageFromFilename(filename))
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -30,7 +30,7 @@ export class FileManager {
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
field: string,
|
field: string,
|
||||||
file: File
|
file: File,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!this.auth.isAuthenticated()) {
|
if (!this.auth.isAuthenticated()) {
|
||||||
throw new Error("User must be authenticated to upload files");
|
throw new Error("User must be authenticated to upload files");
|
||||||
|
@ -42,7 +42,9 @@ export class FileManager {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(field, 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;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to upload file to ${collectionName}:`, err);
|
console.error(`Failed to upload file to ${collectionName}:`, err);
|
||||||
|
@ -64,7 +66,7 @@ export class FileManager {
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
field: string,
|
field: string,
|
||||||
files: File[]
|
files: File[],
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!this.auth.isAuthenticated()) {
|
if (!this.auth.isAuthenticated()) {
|
||||||
throw new Error("User must be authenticated to upload files");
|
throw new Error("User must be authenticated to upload files");
|
||||||
|
@ -73,14 +75,16 @@ export class FileManager {
|
||||||
try {
|
try {
|
||||||
this.auth.setUpdating(true);
|
this.auth.setUpdating(true);
|
||||||
const pb = this.auth.getPocketBase();
|
const pb = this.auth.getPocketBase();
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit
|
||||||
const MAX_BATCH_SIZE = 25 * 1024 * 1024; // 25MB per batch
|
const MAX_BATCH_SIZE = 25 * 1024 * 1024; // 25MB per batch
|
||||||
|
|
||||||
// Validate file sizes first
|
// Validate file sizes first
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
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[] = [];
|
let existingFiles: string[] = [];
|
||||||
if (recordId) {
|
if (recordId) {
|
||||||
try {
|
try {
|
||||||
const record = await pb.collection(collectionName).getOne<T>(recordId);
|
const record = await pb
|
||||||
|
.collection(collectionName)
|
||||||
|
.getOne<T>(recordId);
|
||||||
existingFiles = (record as any)[field] || [];
|
existingFiles = (record as any)[field] || [];
|
||||||
} catch (error) {
|
} 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
|
// Process each file
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
let processedFile = file;
|
let processedFile = file;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to compress image files if needed
|
// 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
|
processedFile = await this.compressImageIfNeeded(file, 50); // 50MB max size
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -118,7 +124,12 @@ export class FileManager {
|
||||||
if (currentBatchSize + processedFile.size > MAX_BATCH_SIZE) {
|
if (currentBatchSize + processedFile.size > MAX_BATCH_SIZE) {
|
||||||
// Upload current batch
|
// Upload current batch
|
||||||
if (currentBatch.length > 0) {
|
if (currentBatch.length > 0) {
|
||||||
await this.uploadBatch(collectionName, recordId, field, currentBatch);
|
await this.uploadBatch(
|
||||||
|
collectionName,
|
||||||
|
recordId,
|
||||||
|
field,
|
||||||
|
currentBatch,
|
||||||
|
);
|
||||||
allProcessedFiles.push(...currentBatch);
|
allProcessedFiles.push(...currentBatch);
|
||||||
}
|
}
|
||||||
// Reset batch
|
// Reset batch
|
||||||
|
@ -138,7 +149,9 @@ export class FileManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the final record state
|
// 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;
|
return finalRecord;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to upload files to ${collectionName}:`, err);
|
console.error(`Failed to upload files to ${collectionName}:`, err);
|
||||||
|
@ -156,7 +169,7 @@ export class FileManager {
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
field: string,
|
field: string,
|
||||||
files: File[]
|
files: File[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const pb = this.auth.getPocketBase();
|
const pb = this.auth.getPocketBase();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
@ -170,7 +183,9 @@ export class FileManager {
|
||||||
await pb.collection(collectionName).update(recordId, formData);
|
await pb.collection(collectionName).update(recordId, formData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.status === 413) {
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -188,7 +203,7 @@ export class FileManager {
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
field: string,
|
field: string,
|
||||||
files: File[]
|
files: File[],
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!this.auth.isAuthenticated()) {
|
if (!this.auth.isAuthenticated()) {
|
||||||
throw new Error("User must be authenticated to upload files");
|
throw new Error("User must be authenticated to upload files");
|
||||||
|
@ -197,10 +212,10 @@ export class FileManager {
|
||||||
try {
|
try {
|
||||||
this.auth.setUpdating(true);
|
this.auth.setUpdating(true);
|
||||||
const pb = this.auth.getPocketBase();
|
const pb = this.auth.getPocketBase();
|
||||||
|
|
||||||
// First, get the current record to check existing files
|
// First, get the current record to check existing files
|
||||||
const record = await pb.collection(collectionName).getOne<T>(recordId);
|
const record = await pb.collection(collectionName).getOne<T>(recordId);
|
||||||
|
|
||||||
// Create FormData with existing files
|
// Create FormData with existing files
|
||||||
const formData = new FormData();
|
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 each existing file, we need to fetch it and add it to the FormData
|
||||||
for (const existingFile of existingFiles) {
|
for (const existingFile of existingFiles) {
|
||||||
try {
|
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 blob = await response.blob();
|
||||||
const file = new File([blob], existingFile, { type: blob.type });
|
const file = new File([blob], existingFile, { type: blob.type });
|
||||||
formData.append(field, file);
|
formData.append(field, file);
|
||||||
|
@ -218,13 +235,15 @@ export class FileManager {
|
||||||
console.warn(`Failed to fetch existing file ${existingFile}:`, error);
|
console.warn(`Failed to fetch existing file ${existingFile}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append new files
|
// Append new files
|
||||||
files.forEach(file => {
|
files.forEach((file) => {
|
||||||
formData.append(field, 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;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to append files to ${collectionName}:`, err);
|
console.error(`Failed to append files to ${collectionName}:`, err);
|
||||||
|
@ -244,12 +263,13 @@ export class FileManager {
|
||||||
public getFileUrl(
|
public getFileUrl(
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
filename: string
|
filename: string,
|
||||||
): string {
|
): string {
|
||||||
const pb = this.auth.getPocketBase();
|
const pb = this.auth.getPocketBase();
|
||||||
const token = pb.authStore.token;
|
return pb.files.getURL(
|
||||||
const url = `${pb.baseUrl}/api/files/${collectionName}/${recordId}/${filename}`;
|
{ id: recordId, collectionId: collectionName },
|
||||||
return url;
|
filename,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -262,7 +282,7 @@ export class FileManager {
|
||||||
public async deleteFile<T = any>(
|
public async deleteFile<T = any>(
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
field: string
|
field: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!this.auth.isAuthenticated()) {
|
if (!this.auth.isAuthenticated()) {
|
||||||
throw new Error("User must be authenticated to delete files");
|
throw new Error("User must be authenticated to delete files");
|
||||||
|
@ -272,7 +292,9 @@ export class FileManager {
|
||||||
this.auth.setUpdating(true);
|
this.auth.setUpdating(true);
|
||||||
const pb = this.auth.getPocketBase();
|
const pb = this.auth.getPocketBase();
|
||||||
const data = { [field]: null };
|
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;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to delete file from ${collectionName}:`, err);
|
console.error(`Failed to delete file from ${collectionName}:`, err);
|
||||||
|
@ -292,7 +314,7 @@ export class FileManager {
|
||||||
public async downloadFile(
|
public async downloadFile(
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
filename: string
|
filename: string,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
if (!this.auth.isAuthenticated()) {
|
if (!this.auth.isAuthenticated()) {
|
||||||
throw new Error("User must be authenticated to download files");
|
throw new Error("User must be authenticated to download files");
|
||||||
|
@ -302,11 +324,11 @@ export class FileManager {
|
||||||
this.auth.setUpdating(true);
|
this.auth.setUpdating(true);
|
||||||
const url = this.getFileUrl(collectionName, recordId, filename);
|
const url = this.getFileUrl(collectionName, recordId, filename);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.blob();
|
const result = await response.blob();
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -327,7 +349,7 @@ export class FileManager {
|
||||||
public async getFiles(
|
public async getFiles(
|
||||||
collectionName: string,
|
collectionName: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
field: string
|
field: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!this.auth.isAuthenticated()) {
|
if (!this.auth.isAuthenticated()) {
|
||||||
throw new Error("User must be authenticated to get files");
|
throw new Error("User must be authenticated to get files");
|
||||||
|
@ -336,18 +358,18 @@ export class FileManager {
|
||||||
try {
|
try {
|
||||||
this.auth.setUpdating(true);
|
this.auth.setUpdating(true);
|
||||||
const pb = this.auth.getPocketBase();
|
const pb = this.auth.getPocketBase();
|
||||||
|
|
||||||
// Get the record to retrieve the filenames
|
// Get the record to retrieve the filenames
|
||||||
const record = await pb.collection(collectionName).getOne(recordId);
|
const record = await pb.collection(collectionName).getOne(recordId);
|
||||||
|
|
||||||
// Get the filenames from the specified field
|
// Get the filenames from the specified field
|
||||||
const filenames = record[field] || [];
|
const filenames = record[field] || [];
|
||||||
|
|
||||||
// Convert filenames to URLs
|
// Convert filenames to URLs
|
||||||
const fileUrls = filenames.map((filename: string) =>
|
const fileUrls = filenames.map((filename: string) =>
|
||||||
this.getFileUrl(collectionName, recordId, filename)
|
this.getFileUrl(collectionName, recordId, filename),
|
||||||
);
|
);
|
||||||
|
|
||||||
return fileUrls;
|
return fileUrls;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to get files from ${collectionName}:`, err);
|
console.error(`Failed to get files from ${collectionName}:`, err);
|
||||||
|
@ -363,8 +385,11 @@ export class FileManager {
|
||||||
* @param maxSizeInMB Maximum size in MB
|
* @param maxSizeInMB Maximum size in MB
|
||||||
* @returns Promise<File> The compressed file
|
* @returns Promise<File> The compressed file
|
||||||
*/
|
*/
|
||||||
public async compressImageIfNeeded(file: File, maxSizeInMB: number = 50): Promise<File> {
|
public async compressImageIfNeeded(
|
||||||
if (!file.type.startsWith('image/')) {
|
file: File,
|
||||||
|
maxSizeInMB: number = 50,
|
||||||
|
): Promise<File> {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -379,12 +404,12 @@ export class FileManager {
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = e.target?.result as string;
|
img.src = e.target?.result as string;
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement("canvas");
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
let height = img.height;
|
let height = img.height;
|
||||||
|
|
||||||
// Calculate new dimensions while maintaining aspect ratio
|
// Calculate new dimensions while maintaining aspect ratio
|
||||||
const maxDimension = 3840; // Higher quality for larger files
|
const maxDimension = 3840; // Higher quality for larger files
|
||||||
if (width > height && width > maxDimension) {
|
if (width > height && width > maxDimension) {
|
||||||
|
@ -397,35 +422,155 @@ export class FileManager {
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
ctx?.drawImage(img, 0, 0, width, height);
|
ctx?.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
// Convert to blob with higher quality for larger files
|
// Convert to blob with higher quality for larger files
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
reject(new Error('Failed to compress image'));
|
reject(new Error("Failed to compress image"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(new File([blob], file.name, {
|
resolve(
|
||||||
type: 'image/jpeg',
|
new File([blob], file.name, {
|
||||||
lastModified: Date.now(),
|
type: "image/jpeg",
|
||||||
}));
|
lastModified: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
'image/jpeg',
|
"image/jpeg",
|
||||||
0.85 // Higher quality setting for larger files
|
0.85, // Higher quality setting for larger files
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
reject(new Error('Failed to load image for compression'));
|
reject(new Error("Failed to load image for compression"));
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue