import { useEffect, useState, useCallback, useMemo, useRef } 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'; // Image component with fallback handling interface ImageWithFallbackProps { url: string; filename: string; onError: (message: string) => void; } const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => { const [imgSrc, setImgSrc] = useState(url); const [isObjectUrl, setIsObjectUrl] = useState(false); const [errorCount, setErrorCount] = useState(0); const maxRetries = 2; // Clean up object URL when component unmounts useEffect(() => { return () => { if (isObjectUrl && imgSrc !== url) { URL.revokeObjectURL(imgSrc); } }; }, [imgSrc, url, isObjectUrl]); // Reset when URL changes useEffect(() => { setImgSrc(url); setIsObjectUrl(false); setErrorCount(0); }, [url]); // Special handling for blob URLs useEffect(() => { const handleBlobUrl = async () => { if (url.startsWith('blob:') && !isObjectUrl) { try { // For blob URLs, we don't need to fetch again, just set directly setImgSrc(url); } catch (error) { console.error('Error with blob URL:', error); } } }; handleBlobUrl(); }, [url, isObjectUrl]); const handleError = async () => { // Prevent infinite retry loops if (errorCount >= maxRetries) { console.error(`Image failed to load after ${maxRetries} attempts:`, url); onError('Failed to load image after multiple attempts. The file may be corrupted or unsupported.'); return; } setErrorCount(prev => prev + 1); console.error(`Image failed to load (attempt ${errorCount + 1}):`, url); try { // Skip fetch for blob URLs that already failed if (url.startsWith('blob:')) { throw new Error('Blob URL failed to load directly'); } // Try to fetch the image as a blob and create an object URL const response = await fetch(url, { mode: 'cors', cache: 'no-cache' // Avoid caching issues }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); // Update the image source with the object URL setImgSrc(objectUrl); setIsObjectUrl(true); } catch (fetchError) { console.error('Error fetching image as blob:', fetchError); // Only show error to user on final retry if (errorCount >= maxRetries - 1) { onError('Failed to load image. This might be due to permission issues or the file may not exist.'); } } }; return ( {filename ); }; // Cache for file content const contentCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes interface FilePreviewProps { url?: string; filename?: string; isModal?: boolean; } export default function FilePreview({ url: initialUrl = '', filename: initialFilename = '', isModal = false }: FilePreviewProps) { // Constants const CHUNK_SIZE = 50; const INITIAL_LINES_TO_SHOW = 20; // Consolidate state management with useRef for latest values const latestPropsRef = useRef({ url: initialUrl, filename: initialFilename }); const loadingRef = useRef(false); // Add a ref to track loading state const [state, setState] = useState({ url: initialUrl, filename: initialFilename, content: null as string | 'image' | 'video' | 'pdf' | null, error: null as string | null, loading: false, fileType: null as string | null, isVisible: false, visibleLines: INITIAL_LINES_TO_SHOW }); // Memoize the truncated filename const truncatedFilename = useMemo(() => { if (!state.filename) return ''; const maxLength = 40; if (state.filename.length <= maxLength) return state.filename; const extension = state.filename.split('.').pop(); const name = state.filename.substring(0, state.filename.lastIndexOf('.')); const truncatedName = name.substring(0, maxLength - 3 - (extension?.length || 0)); return `${truncatedName}...${extension ? `.${extension}` : ''}`; }, [state.filename]); // Update ref when props change useEffect(() => { latestPropsRef.current = { url: initialUrl, filename: initialFilename }; loadingRef.current = false; // Reset loading ref // Clear state when URL changes setState(prev => ({ ...prev, url: initialUrl, filename: initialFilename, content: null, error: null, fileType: null, loading: false })); }, [initialUrl, initialFilename]); // Single effect for modal event handling useEffect(() => { if (isModal) { const handleStateChange = (event: CustomEvent<{ url: string; filename: string }>) => { const { url: newUrl, filename: newFilename } = event.detail; // Force clear cache for PDFs to prevent stale content if (newUrl.endsWith('.pdf')) { contentCache.delete(`${newUrl}_${newFilename}`); } setState(prev => ({ ...prev, url: newUrl, filename: newFilename, content: null, error: null, fileType: null, loading: true })); }; window.addEventListener('filePreviewStateChange', handleStateChange as EventListener); return () => { window.removeEventListener('filePreviewStateChange', handleStateChange as EventListener); }; } }, [isModal]); // Consolidated content loading effect const loadContent = useCallback(async () => { if (!state.url) { setState(prev => ({ ...prev, error: 'No file URL provided', loading: false })); return; } // Prevent duplicate loading if (loadingRef.current) { return; } loadingRef.current = true; setState(prev => ({ ...prev, loading: true, error: null })); try { // Check cache first const cacheKey = `${state.url}_${state.filename}`; const cachedData = contentCache.get(cacheKey); if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { // Use cached data setState(prev => ({ ...prev, content: cachedData.content, fileType: cachedData.fileType, loading: false })); loadingRef.current = false; return; } // Special handling for PDFs if (state.url.endsWith('.pdf')) { setState(prev => ({ ...prev, content: 'pdf', fileType: 'application/pdf', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'pdf', fileType: 'application/pdf', timestamp: Date.now() }); loadingRef.current = false; return; } // Handle image files const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']; if (imageExtensions.some(ext => state.url.toLowerCase().endsWith(ext) || (state.filename && state.filename.toLowerCase().endsWith(ext)))) { setState(prev => ({ ...prev, content: 'image', fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'), loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'image', fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'), timestamp: Date.now() }); loadingRef.current = false; return; } // Handle video files const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi']; if (videoExtensions.some(ext => state.url.toLowerCase().endsWith(ext) || (state.filename && state.filename.toLowerCase().endsWith(ext)))) { setState(prev => ({ ...prev, content: 'video', fileType: 'video/' + (state.url.split('.').pop() || 'mp4'), loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'video', fileType: 'video/' + (state.url.split('.').pop() || 'mp4'), timestamp: Date.now() }); loadingRef.current = false; return; } // For other file types, try to fetch the content // Handle blob URLs (for local file previews) if (state.url.startsWith('blob:')) { try { // Determine file type from filename if available let fileType = ''; if (state.filename) { const extension = state.filename.split('.').pop()?.toLowerCase(); if (extension) { switch (extension) { case 'jpg': case 'jpeg': case 'png': case 'gif': case 'webp': case 'bmp': case 'svg': fileType = `image/${extension === 'jpg' ? 'jpeg' : extension}`; break; case 'mp4': case 'webm': case 'ogg': case 'mov': fileType = `video/${extension}`; break; case 'pdf': fileType = 'application/pdf'; break; case 'doc': fileType = 'application/msword'; break; case 'docx': fileType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; break; case 'xls': fileType = 'application/vnd.ms-excel'; break; case 'xlsx': fileType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; break; case 'ppt': fileType = 'application/vnd.ms-powerpoint'; break; case 'pptx': fileType = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; break; case 'txt': case 'md': case 'js': case 'jsx': case 'ts': case 'tsx': case 'html': case 'css': case 'json': case 'yml': case 'yaml': case 'csv': fileType = 'text/plain'; break; default: fileType = 'application/octet-stream'; } } } // Try to fetch the blob const response = await fetch(state.url); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); } const blob = await response.blob(); // If we couldn't determine file type from filename, use the blob type if (!fileType && blob.type) { fileType = blob.type; } // Handle different file types if (fileType.startsWith('image/') || (state.filename && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(state.filename))) { setState(prev => ({ ...prev, content: 'image', fileType: fileType || 'image/jpeg', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'image', fileType: fileType || 'image/jpeg', timestamp: Date.now() }); } else if (fileType.startsWith('video/') || (state.filename && /\.(mp4|webm|ogg|mov|avi)$/i.test(state.filename))) { setState(prev => ({ ...prev, content: 'video', fileType: fileType || 'video/mp4', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'video', fileType: fileType || 'video/mp4', timestamp: Date.now() }); } else if (fileType === 'application/pdf' || (state.filename && /\.pdf$/i.test(state.filename))) { setState(prev => ({ ...prev, content: 'pdf', fileType: 'application/pdf', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'pdf', fileType: 'application/pdf', timestamp: Date.now() }); } else if ( fileType === 'application/msword' || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileType === 'application/vnd.ms-excel' || fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || fileType === 'application/vnd.ms-powerpoint' || fileType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || (state.filename && /\.(doc|docx|xls|xlsx|ppt|pptx)$/i.test(state.filename)) ) { // Handle Office documents with a document icon and download option const extension = state.filename?.split('.').pop()?.toLowerCase() || ''; let documentType = 'document'; if (['xls', 'xlsx'].includes(extension)) { documentType = 'spreadsheet'; } else if (['ppt', 'pptx'].includes(extension)) { documentType = 'presentation'; } setState(prev => ({ ...prev, content: `document-${documentType}`, fileType: fileType || `application/${documentType}`, loading: false })); // Cache the result contentCache.set(cacheKey, { content: `document-${documentType}`, fileType: fileType || `application/${documentType}`, timestamp: Date.now() }); } else { // For text files, read the content try { const text = await blob.text(); setState(prev => ({ ...prev, content: text, fileType: fileType || 'text/plain', loading: false })); // Cache the result contentCache.set(cacheKey, { content: text, fileType: fileType || 'text/plain', timestamp: Date.now() }); } catch (textError) { console.error('Error reading blob as text:', textError); throw new Error('Failed to read file content'); } } loadingRef.current = false; return; } catch (error) { console.error('Error processing blob URL:', error); setState(prev => ({ ...prev, error: 'Failed to load file preview. Please try again or proceed with upload.', loading: false })); loadingRef.current = false; return; } } // For remote files const response = await fetch(state.url); if (!response.ok) { throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); } const contentType = response.headers.get('content-type') || ''; if (contentType.startsWith('image/')) { setState(prev => ({ ...prev, content: 'image', fileType: contentType, loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'image', fileType: contentType, timestamp: Date.now() }); } else if (contentType.startsWith('video/')) { setState(prev => ({ ...prev, content: 'video', fileType: contentType, loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'video', fileType: contentType, timestamp: Date.now() }); } else if (contentType === 'application/pdf') { setState(prev => ({ ...prev, content: 'pdf', fileType: contentType, loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'pdf', fileType: contentType, timestamp: Date.now() }); } else if ( contentType === 'application/msword' || contentType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || (state.filename && /\.(doc|docx)$/i.test(state.filename)) ) { setState(prev => ({ ...prev, content: 'document-document', fileType: contentType || 'application/document', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'document-document', fileType: contentType || 'application/document', timestamp: Date.now() }); } else if ( contentType === 'application/vnd.ms-excel' || contentType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || (state.filename && /\.(xls|xlsx)$/i.test(state.filename)) ) { setState(prev => ({ ...prev, content: 'document-spreadsheet', fileType: contentType || 'application/spreadsheet', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'document-spreadsheet', fileType: contentType || 'application/spreadsheet', timestamp: Date.now() }); } else if ( contentType === 'application/vnd.ms-powerpoint' || contentType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || (state.filename && /\.(ppt|pptx)$/i.test(state.filename)) ) { setState(prev => ({ ...prev, content: 'document-presentation', fileType: contentType || 'application/presentation', loading: false })); // Cache the result contentCache.set(cacheKey, { content: 'document-presentation', fileType: contentType || 'application/presentation', timestamp: Date.now() }); } else { // For text files, read the content const text = await response.text(); setState(prev => ({ ...prev, content: text, fileType: contentType, loading: false })); // Cache the result contentCache.set(cacheKey, { content: text, fileType: contentType, timestamp: Date.now() }); } } catch (err) { console.error('Error loading content:', err); setState(prev => ({ ...prev, error: err instanceof Error ? err.message : 'Failed to load file', loading: false })); loadingRef.current = false; } }, [state.url]); useEffect(() => { if (!state.url) return; // For modal, only load when visible if (isModal && !state.isVisible) return; // Reset loading state when URL changes loadingRef.current = false; // Small timeout to ensure state updates are processed const timer = setTimeout(() => { loadContent(); }, 50); return () => clearTimeout(timer); }, [state.url, state.isVisible, isModal, loadContent]); // Intersection observer effect useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { setState(prev => ({ ...prev, isVisible: entry.isIntersecting })); }, { threshold: 0.1 } ); const previewElement = document.querySelector('.file-preview-container'); if (previewElement) { observer.observe(previewElement); } return () => observer.disconnect(); }, []); const handleDownload = async () => { try { const response = await fetch(state.url); const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = state.filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); } catch (err) { console.error('Error downloading file:', err); alert('Failed to download file. Please try again.'); } }; const getLanguageFromFilename = (filename: string): string => { const extension = filename.split('.').pop()?.toLowerCase(); switch (extension) { case 'js': case 'jsx': return 'javascript'; case 'ts': case 'tsx': return 'typescript'; case 'py': return 'python'; case 'html': return 'html'; case 'css': return 'css'; case 'json': return 'json'; case 'md': return 'markdown'; case 'yml': case 'yaml': return 'yaml'; case 'csv': return 'csv'; case 'txt': return 'plaintext'; default: // If no extension or unrecognized extension, default to plaintext return 'plaintext'; } }; const parseCSV = useCallback((csvContent: string) => { const lines = csvContent.split('\n').map(line => line.split(',').map(cell => cell.trim().replace(/^["'](.*)["']$/, '$1') ) ); const headers = lines[0]; const dataRows = lines.slice(1).filter(row => row.some(cell => cell.length > 0)); // Skip empty rows // Remove the truncation message if it exists const lastRow = dataRows[dataRows.length - 1]; if (lastRow && lastRow[0] && lastRow[0].includes('Content truncated')) { dataRows.pop(); } return { headers, rows: dataRows }; }, []); const renderCSVTable = useCallback((csvContent: string) => { const { headers, rows } = parseCSV(csvContent); const totalRows = rows.length; const rowsToShow = Math.min(state.visibleLines, totalRows); const displayedRows = rows.slice(0, rowsToShow); return `
${headers.map(header => ``).join('')} ${displayedRows.map((row, rowIndex) => ` ${row.map(cell => ``).join('')} `).join('')} ${rowsToShow < totalRows ? ` ` : ''}
${header}
${cell}
... ${totalRows - rowsToShow} more rows
`; }, [state.visibleLines]); const formatCodeWithLineNumbers = useCallback((code: string, language: string) => { try { // Use highlight.js to highlight the code const highlighted = hljs.highlight(code, { language }).value; const lines = highlighted.split('\n'); return lines.map((line, i) => `
${i + 1} ${line || ' '}
` ).join(''); } catch (error) { console.warn(`Failed to highlight code as ${language}, falling back to plaintext`); const plaintext = hljs.highlight(code, { language: 'plaintext' }).value; const lines = plaintext.split('\n'); return lines.map((line, i) => `
${i + 1} ${line || ' '}
` ).join(''); } }, []); const highlightCode = useCallback((code: string, language: string) => { // Skip highlighting for CSV if (language === 'csv') { return code; } return code; // Just return the code, formatting is handled in formatCodeWithLineNumbers }, []); const handleShowMore = useCallback(() => { setState(prev => ({ ...prev, visibleLines: Math.min(prev.visibleLines + CHUNK_SIZE, (prev.content as string).split('\n').length) })); }, []); const handleShowLess = useCallback(() => { setState(prev => ({ ...prev, visibleLines: INITIAL_LINES_TO_SHOW })); }, []); // Update the Try Again button handler const handleTryAgain = useCallback(() => { loadingRef.current = false; // Reset loading ref setState(prev => ({ ...prev, error: null, loading: true })); setTimeout(() => { loadContent(); }, 100); // Small delay to ensure state is updated }, [loadContent]); // If URL is empty, show a message if (!state.url) { return (

No File URL Provided

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

); } return (
{truncatedFilename} {state.fileType && ( {state.fileType.split('/')[1]} )}
{/* Initial loading state - show immediately when URL is provided but content hasn't loaded yet */} {state.url && !state.loading && !state.error && state.content === null && (
)} {state.loading && (
)} {state.error && (

Preview Unavailable

{state.error}

)} {!state.loading && !state.error && state.content === 'image' && (
setState(prev => ({ ...prev, error: message }))} />
)} {!state.loading && !state.error && state.content === 'video' && (
)} {!state.loading && !state.error && state.content === 'pdf' && (
{/* Use object tag instead of iframe for better PDF support */} { console.error('PDF object failed to load:', e); // Create a fallback div with a download link const obj = e.target as HTMLObjectElement; const container = obj.parentElement; if (container) { container.innerHTML = `

PDF Preview Unavailable

The PDF cannot be displayed in the browser due to security restrictions.

Download PDF Instead
`; } }} >

PDF Preview Unavailable

Your browser cannot display this PDF or it failed to load.

Download PDF Instead
)} {!state.loading && !state.error && state.content && state.content.startsWith('document-') && (

{state.filename}

This document cannot be previewed in the browser. Please download it to view its contents.

Download { state.content === 'document-spreadsheet' ? 'Spreadsheet' : state.content === 'document-presentation' ? 'Presentation' : 'Document' }
)} {!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
{state.filename.toLowerCase().endsWith('.csv') ? (
) : ( <>
{state.content.split('\n').length > state.visibleLines && (
{state.visibleLines < state.content.split('\n').length && ( )} {state.visibleLines > INITIAL_LINES_TO_SHOW && ( )}
)} )}
)}
); }