add file preview

This commit is contained in:
chark1es 2025-02-15 03:01:43 -08:00
parent 151b7a506e
commit 6e9bb124c4
3 changed files with 421 additions and 484 deletions

View file

@ -672,7 +672,7 @@ const currentPage = eventResponse.page;
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
<button onclick="window.closeEventDetailsModal()">close</button>
</form>
</dialog>
@ -699,17 +699,12 @@ const currentPage = eventResponse.page;
<span class="loading loading-spinner loading-lg"></span>
</div>
<div id="previewContent" class="w-full">
<FilePreview
client:load
url=""
filename=""
id="universalFilePreview"
/>
<FilePreview client:load url="" filename="" id="universalFilePreview" />
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
<button onclick="window.closeFilePreview()">close</button>
</form>
</dialog>
@ -1834,48 +1829,62 @@ const currentPage = eventResponse.page;
// Universal file preview function
window.previewFile = function (url: string, filename: string) {
const modal = document.getElementById(
"filePreviewModal"
) as HTMLDialogElement;
const modal = document.getElementById("filePreviewModal") as HTMLDialogElement;
const filePreview = document.getElementById("universalFilePreview") as any;
const previewFileName = document.getElementById("previewFileName");
const filePreview = document.getElementById("universalFilePreview");
const loadingSpinner = document.getElementById("previewLoadingSpinner");
if (!modal || !previewFileName || !filePreview || !loadingSpinner)
return;
// Show modal and update filename
modal.showModal();
previewFileName.textContent = filename;
// Show loading spinner
loadingSpinner.classList.remove("hidden");
try {
// Update the FilePreview component
if (filePreview && modal && previewFileName) {
// Update the preview component
const event = new CustomEvent("updateFilePreview", {
detail: { url, filename },
});
filePreview.dispatchEvent(event);
} finally {
// Hide loading spinner
loadingSpinner.classList.add("hidden");
// Update the filename display
previewFileName.textContent = filename;
// Show the modal
modal.showModal();
}
};
// Close file preview
window.closeFilePreview = function () {
const modal = document.getElementById(
"filePreviewModal"
) as HTMLDialogElement;
const modal = document.getElementById("filePreviewModal") as HTMLDialogElement;
const filePreview = document.getElementById("universalFilePreview");
const previewFileName = document.getElementById("previewFileName");
const filesContent = document.getElementById("filesContent");
if (modal && filePreview) {
if (modal && filePreview && previewFileName) {
// Reset the preview
const event = new CustomEvent("updateFilePreview", {
detail: { url: "", filename: "" },
});
filePreview.dispatchEvent(event);
previewFileName.textContent = "";
modal.close();
// Show the files list if we're in the event details modal
if (filesContent) {
filesContent.classList.remove('hidden');
}
}
};
// Close event details modal
window.closeEventDetailsModal = function () {
const modal = document.getElementById("eventDetailsModal") as HTMLDialogElement;
const filesContent = document.getElementById("filesContent");
const attendeesContent = document.getElementById("attendeesContent");
if (modal) {
// Reset tab states
if (filesContent && attendeesContent) {
filesContent.classList.remove('hidden');
attendeesContent.classList.add('hidden');
}
// Close the modal
modal.close();
}
};
@ -1884,16 +1893,31 @@ const currentPage = eventResponse.page;
function updateFilePreviewButtons(files: string[], eventId: string) {
return files
.map(
(filename) => `
(filename) => {
const fileUrl = fileManager.getFileUrl("events", eventId, filename);
return `
<div class="flex items-center justify-between p-2 bg-base-200 rounded-lg${filesToDelete.has(filename) ? " opacity-50" : ""}" data-filename="${filename}">
<span class="truncate">${filename}</span>
<div class="flex gap-2">
<button type="button" class="btn btn-ghost btn-xs" onclick="window.previewFile('${fileManager.getFileUrl("events", eventId, filename)}', '${filename}')">
<div class="flex items-center gap-2 flex-1 min-w-0">
<button type="button" class="btn btn-ghost btn-xs" onclick="window.previewFile('${fileUrl}', '${filename}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/>
</svg>
</button>
<span class="truncate">${filename}</span>
</div>
<div class="flex gap-2 flex-shrink-0">
<a
href="${fileUrl}"
download
class="btn btn-ghost btn-xs"
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</a>
<div class="text-error">
${
filesToDelete.has(filename)
@ -1907,7 +1931,8 @@ const currentPage = eventResponse.page;
</div>
</div>
</div>
`
`;
}
)
.join("");
}
@ -1919,12 +1944,30 @@ const currentPage = eventResponse.page;
) as HTMLDialogElement;
const filesContent = document.getElementById("filesContent");
const attendeesContent = document.getElementById("attendeesContent");
const tabs = document.querySelectorAll('.tabs .tab');
if (!modal || !filesContent || !attendeesContent) return;
if (!modal || !filesContent || !attendeesContent || !tabs) return;
// Show modal
modal.showModal();
// Add tab functionality
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const tabName = (tab as HTMLElement).dataset.tab;
tabs.forEach(t => t.classList.remove('tab-active'));
tab.classList.add('tab-active');
if (tabName === 'files') {
filesContent.classList.remove('hidden');
attendeesContent.classList.add('hidden');
} else {
filesContent.classList.add('hidden');
attendeesContent.classList.remove('hidden');
}
});
});
// Update files list
if (event.files && event.files.length > 0) {
filesContent.innerHTML = `
@ -1939,6 +1982,14 @@ const currentPage = eventResponse.page;
</div>
`;
}
// Show files tab by default
const filesTab = Array.from(tabs).find(tab => (tab as HTMLElement).dataset.tab === 'files');
if (filesTab) {
filesTab.classList.add('tab-active');
filesContent.classList.remove('hidden');
attendeesContent.classList.add('hidden');
}
};
// Add file input change handler to show selected files

View file

@ -1,4 +1,10 @@
import { useState, useEffect } from "react";
import { Get } from "../../pocketbase/Get";
import { Authentication } from "../../pocketbase/Authentication";
import { Update } from "../../pocketbase/Update";
import { FileManager } from "../../pocketbase/FileManager";
import { SendLog } from "../../pocketbase/SendLog";
import FilePreview from "../../modals/FilePreview";
// Extend Window interface
declare global {
@ -7,12 +13,6 @@ declare global {
hideLoading?: () => void;
}
}
import FilePreview from "../../modals/FilePreview";
import { Get } from "../../pocketbase/Get";
import { Authentication } from "../../pocketbase/Authentication";
import { Update } from "../../pocketbase/Update";
import { FileManager } from "../../pocketbase/FileManager";
import { SendLog } from "../../pocketbase/SendLog";
interface Event {
id: string;
@ -257,35 +257,18 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
};
// Add preview section
const previewSection = (
<div id="editModalPreviewSection" className={`${showPreview ? "" : "hidden"}`}>
<div className="flex items-center gap-3 mb-4">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowPreview(false)}
>
Back
</button>
<h3 className="font-bold text-lg truncate" id="editPreviewFileName">
{previewFilename}
</h3>
</div>
<div className="relative">
<div
id="editLoadingSpinner"
className="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden"
>
<span className="loading loading-spinner loading-lg"></span>
</div>
<div id="previewContent" className="w-full">
const previewSection = showPreview && event?.id && (
<FilePreview
url={previewUrl}
url={fileManager.getFileUrl("events", event.id, previewFilename)}
filename={previewFilename}
id="editFilePreview"
onClose={() => {
setShowPreview(false);
const modal = document.getElementById('filePreviewModal') as HTMLDialogElement;
if (modal) {
modal.close();
}
}}
/>
</div>
</div>
</div>
);
return (

View file

@ -1,308 +1,145 @@
import React from 'react';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
import { useState, useEffect } from "react";
import { FileManager } from "../pocketbase/FileManager";
interface FilePreviewProps {
url: string;
filename: string;
id?: string;
onClose?: () => void;
}
type JSXElement = React.ReactElement;
export default function FilePreview({ url, filename, onClose }: FilePreviewProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [fileType, setFileType] = useState<string | null>(null);
const [modalElement, setModalElement] = useState<HTMLDialogElement | null>(null);
const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl, filename: initialFilename, id }) => {
const [url, setUrl] = React.useState(initialUrl);
const [filename, setFilename] = React.useState(initialFilename);
const [visibleLines, setVisibleLines] = React.useState(20);
const elementRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
const modal = document.getElementById('filePreviewModal') as HTMLDialogElement;
setModalElement(modal);
// Constants for text preview
const INITIAL_LINES = 20;
const INCREMENT_LINES = 50;
const MAX_CHARS_PER_LINE = 120;
const TRUNCATION_MESSAGE = '...';
const initializeViewer = async () => {
try {
setLoading(true);
setError(null);
setFileContent(null);
// Determine file type from extension
const fileExtension = filename.split('.').pop()?.toLowerCase() || '';
const extension = filename.split('.').pop()?.toLowerCase() || '';
const type = getFileType(extension);
setFileType(type);
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(fileExtension);
const isPDF = fileExtension === 'pdf';
const isCode = [
'py', 'js', 'jsx', 'ts', 'tsx', 'html', 'htm', 'css', 'scss',
'java', 'c', 'cpp', 'cs', 'go', 'rs', 'sql', 'php', 'rb',
'swift', 'kt', 'sh', 'bash', 'yaml', 'yml', 'json', 'md',
'astro', 'vue', 'svelte', 'xml', 'toml', 'ini', 'env',
'graphql', 'prisma', 'dockerfile', 'nginx'
].includes(fileExtension);
const isText = isCode || ['txt', 'log', 'csv'].includes(fileExtension);
const isVideo = ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(fileExtension);
const isAudio = ['mp3', 'wav', 'm4a', 'ogg'].includes(fileExtension);
// If it's a code file, fetch its content
if (type === 'code') {
await fetchCodeContent(url);
}
// Function to highlight code using highlight.js
const highlightCode = (code: string, language?: string): string => {
if (!language) return code;
try {
return hljs.highlight(code, { language }).value;
} catch (error) {
console.warn(`Failed to highlight code for language ${language}:`, error);
return code;
setLoading(false);
} catch (err) {
setError("Failed to load file preview");
setLoading(false);
}
};
// Function to get the appropriate language for highlight.js
const getHighlightLanguage = (ext: string): string | undefined => {
// Map file extensions to highlight.js languages
const languageMap: { [key: string]: string } = {
'py': 'python',
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'html': 'html',
'htm': 'html',
'css': 'css',
'scss': 'scss',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'rs': 'rust',
'sql': 'sql',
'php': 'php',
'rb': 'ruby',
'swift': 'swift',
'kt': 'kotlin',
'sh': 'bash',
'bash': 'bash',
'yaml': 'yaml',
'yml': 'yaml',
'json': 'json',
'md': 'markdown',
'xml': 'xml',
'toml': 'ini',
'ini': 'ini',
'dockerfile': 'dockerfile',
'prisma': 'prisma',
'graphql': 'graphql'
};
return languageMap[ext];
};
// Only initialize if we have both url and filename
if (url && filename) {
initializeViewer();
if (modal && !modal.open) {
modal.showModal();
}
}
// Function to truncate text content
const truncateContent = (text: string, maxLines: number): string => {
const lines = text.split('\n');
if (lines.length <= maxLines) return text;
const truncatedLines = lines.slice(0, maxLines).map(line =>
line.length > MAX_CHARS_PER_LINE
? line.slice(0, MAX_CHARS_PER_LINE) + '...'
: line
);
return truncatedLines.join('\n') + '\n' + TRUNCATION_MESSAGE;
};
// Reset visible lines when file changes
React.useEffect(() => {
setVisibleLines(INITIAL_LINES);
}, [url]);
// Function to show more lines
const showMoreLines = () => {
setVisibleLines(prev => prev + INCREMENT_LINES);
};
// Function to reset to initial view
const resetView = () => {
setVisibleLines(INITIAL_LINES);
};
// Listen for custom events to update the preview
React.useEffect(() => {
const element = elementRef.current;
if (!element) return;
const handleUpdatePreview = (e: CustomEvent<{ url: string; filename: string }>) => {
setUrl(e.detail.url);
setFilename(e.detail.filename);
};
element.addEventListener('updateFilePreview', handleUpdatePreview as EventListener);
// Cleanup function
return () => {
element.removeEventListener('updateFilePreview', handleUpdatePreview as EventListener);
if (modal?.open) {
modal.close();
}
setFileContent(null);
};
}, []);
}, [url, filename]);
// Update state when props change
React.useEffect(() => {
setUrl(initialUrl);
setFilename(initialFilename);
}, [initialUrl, initialFilename]);
const handleClose = () => {
if (modalElement?.open) {
modalElement.close();
}
onClose?.();
};
// For text files, we need to fetch and display the content
const [textContent, setTextContent] = React.useState<string>('');
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
React.useEffect(() => {
async function fetchTextContent() {
if (!isText) return;
const getFileType = (extension: string): string => {
const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const videoTypes = ['mp4', 'webm', 'ogg', 'mov'];
const documentTypes = ['pdf', 'doc', 'docx', 'txt', 'md'];
const spreadsheetTypes = ['xls', 'xlsx', 'csv'];
const presentationTypes = ['ppt', 'pptx'];
const codeTypes = ['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'json', 'py', 'java', 'cpp', 'h', 'c', 'cs', 'php', 'rb', 'swift', 'go', 'rs'];
setIsLoading(true);
setError(null);
if (imageTypes.includes(extension)) return 'image';
if (videoTypes.includes(extension)) return 'video';
if (documentTypes.includes(extension)) return 'document';
if (spreadsheetTypes.includes(extension)) return 'spreadsheet';
if (presentationTypes.includes(extension)) return 'presentation';
if (codeTypes.includes(extension)) return 'code';
return 'other';
};
// Function to fetch and set code content
const fetchCodeContent = async (url: string) => {
try {
const response = await fetch(url);
const text = await response.text();
setTextContent(text);
setFileContent(text);
} catch (err) {
setError('Failed to load text content');
console.error('Error fetching text content:', err);
} finally {
setIsLoading(false);
}
}
if (isText) {
fetchTextContent();
}
}, [url, isText]);
// Function to parse CSV text into array
const parseCSV = (text: string): string[][] => {
const rows = text.split(/\r?\n/).filter(row => row.trim());
return rows.map(row => {
// Handle both quoted and unquoted CSV
const matches = row.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || [];
return matches.map(cell => cell.replace(/^"|"$/g, '').trim());
});
};
// Function to format JSON with syntax highlighting
const formatJSON = (text: string): string => {
try {
const parsed = JSON.parse(text);
return highlightCode(JSON.stringify(parsed, null, 2), 'json');
} catch {
return text; // Return original text if not valid JSON
console.error('Failed to fetch code content:', err);
setError('Failed to load code content');
}
};
// Function to render CSV as table
const renderCSVTable = (csvData: string[][]): JSXElement => {
if (csvData.length === 0) return <p>No data</p>;
const headers = csvData[0];
const allRows = csvData.slice(1);
const rows = allRows.slice(0, visibleLines);
const remainingRows = allRows.length - visibleLines;
const hasMore = remainingRows > 0;
const renderFileIcon = () => {
switch (fileType) {
case 'image':
return (
<div className="space-y-4">
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="table table-zebra w-full">
<thead className="sticky top-0 z-10">
<tr>
{headers.map((header, i) => (
<th key={i} className="bg-base-200">{header}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td key={j}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="text-center space-y-2">
{hasMore && (
<button
className="btn btn-ghost btn-sm"
onClick={showMoreLines}
>
Show More ({Math.min(remainingRows, INCREMENT_LINES)} of {remainingRows} rows)
</button>
)}
{visibleLines > INITIAL_LINES && (
<button
className="btn btn-ghost btn-sm"
onClick={resetView}
>
Reset View
</button>
)}
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
};
// Function to render text content based on file type
const renderTextContent = (): JSXElement => {
if (!textContent) return <p>No content</p>;
if (fileExtension === 'csv') {
const csvData = parseCSV(textContent);
return renderCSVTable(csvData);
}
const lines = textContent.split('\n');
const content = truncateContent(textContent, visibleLines);
const remainingLines = lines.length - visibleLines;
const hasMore = remainingLines > 0;
const renderContent = () => {
if (isCode) {
const language = getHighlightLanguage(fileExtension);
const highlightedCode = highlightCode(content, language);
case 'video':
return (
<code
className="text-sm font-mono"
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
);
case 'document':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
case 'code':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
);
case 'spreadsheet':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
);
default:
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
);
}
return <code className="text-sm font-mono">{content}</code>;
};
const renderPreview = () => {
if (loading) {
return (
<div className="space-y-4">
<div className="max-h-[60vh] overflow-y-auto">
<pre className="whitespace-pre-wrap bg-base-200 p-4 rounded-lg overflow-x-auto">
{renderContent()}
</pre>
</div>
<div className="text-center space-y-2">
{hasMore && (
<button
className="btn btn-ghost btn-sm"
onClick={showMoreLines}
>
Show More ({Math.min(remainingLines, INCREMENT_LINES)} of {remainingLines} lines)
</button>
)}
{visibleLines > INITIAL_LINES && (
<button
className="btn btn-ghost btn-sm"
onClick={resetView}
>
Reset View
</button>
)}
</div>
</div>
);
};
if (isLoading) {
return (
<div ref={elementRef} id={id} className="flex justify-center items-center p-8">
<div className="flex items-center justify-center h-96">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
@ -310,106 +147,172 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl, filename: in
if (error) {
return (
<div ref={elementRef} id={id} className="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
<div className="flex flex-col items-center justify-center h-96 text-error">
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{error}</span>
<p>{error}</p>
</div>
);
}
if (isImage) {
switch (fileType) {
case 'image':
return (
<div ref={elementRef} id={id} className="relative w-full max-h-[60vh] overflow-y-auto">
<div className="flex flex-col items-center justify-center gap-4">
<div className="relative group">
<img
src={url}
alt={filename}
className="max-w-full h-auto rounded-lg"
onError={() => setError('Failed to load image')}
className="max-w-full max-h-[calc(100vh-16rem)] object-contain rounded-lg"
onError={() => setError("Failed to load image")}
/>
</div>
);
}
if (isPDF) {
return (
<div ref={elementRef} id={id} className="relative w-full h-[60vh]">
<iframe
src={url}
className="w-full h-full rounded-lg"
title={filename}
/>
</div>
);
}
if (isVideo) {
return (
<div ref={elementRef} id={id} className="relative w-full max-h-[60vh]">
<video
controls
className="w-full rounded-lg"
onError={() => setError('Failed to load video')}
>
<source src={url} type={`video/${fileExtension}`} />
Your browser does not support the video tag.
</video>
</div>
);
}
if (isAudio) {
return (
<div ref={elementRef} id={id} className="relative w-full">
<audio
controls
className="w-full"
onError={() => setError('Failed to load audio')}
>
<source src={url} type={`audio/${fileExtension}`} />
Your browser does not support the audio tag.
</audio>
</div>
);
}
if (isText) {
return (
<div ref={elementRef} id={id} className="relative w-full">
{renderTextContent()}
</div>
);
}
// Default case for unsupported file types
return (
<div ref={elementRef} id={id} className="text-center py-8">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 mx-auto mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<p className="text-base-content/70">Preview not available for this file type</p>
<a
href={url}
download
className="btn btn-sm btn-primary absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity"
target="_blank"
rel="noopener noreferrer"
>
Download
</a>
</div>
</div>
);
case 'video':
return (
<div className="flex flex-col items-center justify-center gap-4">
<video
controls
className="max-w-full max-h-[calc(100vh-16rem)] rounded-lg"
onError={() => setError("Failed to load video")}
>
<source src={url} type={`video/${filename.split('.').pop()}`} />
Your browser does not support the video tag.
</video>
<a
href={url}
download
className="btn btn-primary btn-sm"
target="_blank"
rel="noopener noreferrer"
>
Download Video
</a>
</div>
);
case 'code':
if (fileContent !== null) {
return (
<div className="bg-base-200 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm">
<code>{fileContent}</code>
</pre>
<div className="mt-4 flex justify-end">
<a
href={url}
download
className="btn btn-primary btn-sm"
target="_blank"
rel="noopener noreferrer"
>
Download Source
</a>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
{renderFileIcon()}
<p className="text-lg font-semibold text-center max-w-full truncate px-4">{filename}</p>
<div className="loading loading-spinner loading-md"></div>
</div>
);
case 'document':
if (filename.toLowerCase().endsWith('.pdf')) {
return (
<div className="flex flex-col gap-4">
<iframe
src={url}
className="w-full h-[calc(100vh-16rem)] rounded-lg"
title={filename}
/>
<div className="flex justify-end">
<a
href={url}
download
className="btn btn-primary btn-sm"
target="_blank"
rel="noopener noreferrer"
>
Download PDF
</a>
</div>
</div>
);
}
// For other document types, show download button
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
{renderFileIcon()}
<p className="text-lg font-semibold text-center max-w-full truncate px-4">{filename}</p>
<a
href={url}
download
className="btn btn-primary"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary btn-sm mt-4"
>
Download File
</a>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
{renderFileIcon()}
<p className="text-lg font-semibold text-center max-w-full truncate px-4">{filename}</p>
<a
href={url}
download
className="btn btn-primary"
target="_blank"
rel="noopener noreferrer"
>
Download File
</a>
</div>
);
}
};
export default FilePreview;
// Only render if we have both url and filename
if (!url || !filename) return null;
return (
<dialog id="filePreviewModal" className="modal">
<div className="modal-box max-w-4xl w-full">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
className="btn btn-ghost btn-sm flex-shrink-0"
onClick={handleClose}
>
Back
</button>
<h3 className="font-bold text-lg truncate">
{filename}
</h3>
</div>
</div>
<div className="overflow-auto">
{renderPreview()}
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={handleClose}>close</button>
</form>
</dialog>
);
}