add file preview
This commit is contained in:
parent
151b7a506e
commit
6e9bb124c4
3 changed files with 421 additions and 484 deletions
|
@ -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,30 +1893,46 @@ const currentPage = eventResponse.page;
|
|||
function updateFilePreviewButtons(files: string[], eventId: string) {
|
||||
return files
|
||||
.map(
|
||||
(filename) => `
|
||||
<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}')">
|
||||
<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>
|
||||
<div class="text-error">
|
||||
${
|
||||
filesToDelete.has(filename)
|
||||
? `<button type="button" class="btn btn-ghost btn-xs" onclick="window.undoDeleteFile('${eventId}', '${filename}')">Undo</button>`
|
||||
: `<button type="button" class="btn btn-ghost btn-xs text-error" onclick="window.deleteFile('${eventId}', '${filename}')">
|
||||
<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="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
(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}">
|
||||
<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)
|
||||
? `<button type="button" class="btn btn-ghost btn-xs" onclick="window.undoDeleteFile('${eventId}', '${filename}')">Undo</button>`
|
||||
: `<button type="button" class="btn btn-ghost btn-xs text-error" onclick="window.deleteFile('${eventId}', '${filename}')">
|
||||
<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="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>`
|
||||
}
|
||||
</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
|
||||
|
|
|
@ -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">
|
||||
<FilePreview
|
||||
url={previewUrl}
|
||||
filename={previewFilename}
|
||||
id="editFilePreview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const previewSection = showPreview && event?.id && (
|
||||
<FilePreview
|
||||
url={fileManager.getFileUrl("events", event.id, previewFilename)}
|
||||
filename={previewFilename}
|
||||
onClose={() => {
|
||||
setShowPreview(false);
|
||||
const modal = document.getElementById('filePreviewModal') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,415 +1,318 @@
|
|||
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);
|
||||
|
||||
// Constants for text preview
|
||||
const INITIAL_LINES = 20;
|
||||
const INCREMENT_LINES = 50;
|
||||
const MAX_CHARS_PER_LINE = 120;
|
||||
const TRUNCATION_MESSAGE = '...';
|
||||
|
||||
// Determine file type from extension
|
||||
const fileExtension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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];
|
||||
};
|
||||
|
||||
// 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);
|
||||
return () => {
|
||||
element.removeEventListener('updateFilePreview', handleUpdatePreview as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update state when props change
|
||||
React.useEffect(() => {
|
||||
setUrl(initialUrl);
|
||||
setFilename(initialFilename);
|
||||
}, [initialUrl, initialFilename]);
|
||||
|
||||
// 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);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchTextContent() {
|
||||
if (!isText) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
useEffect(() => {
|
||||
const modal = document.getElementById('filePreviewModal') as HTMLDialogElement;
|
||||
setModalElement(modal);
|
||||
|
||||
const initializeViewer = async () => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
setTextContent(text);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setFileContent(null);
|
||||
|
||||
// Determine file type from extension
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const type = getFileType(extension);
|
||||
setFileType(type);
|
||||
|
||||
// If it's a code file, fetch its content
|
||||
if (type === 'code') {
|
||||
await fetchCodeContent(url);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to load text content');
|
||||
console.error('Error fetching text content:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setError("Failed to load file preview");
|
||||
setLoading(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
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
return (
|
||||
<code
|
||||
className="text-sm font-mono"
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <code className="text-sm font-mono">{content}</code>;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
// Only initialize if we have both url and filename
|
||||
if (url && filename) {
|
||||
initializeViewer();
|
||||
if (modal && !modal.open) {
|
||||
modal.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (modal?.open) {
|
||||
modal.close();
|
||||
}
|
||||
setFileContent(null);
|
||||
};
|
||||
}, [url, filename]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (modalElement?.open) {
|
||||
modalElement.close();
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div ref={elementRef} id={id} className="flex justify-center items-center p-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
|
||||
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" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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'];
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<div ref={elementRef} id={id} className="relative w-full max-h-[60vh] overflow-y-auto">
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
onError={() => setError('Failed to load image')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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';
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Function to fetch and set code content
|
||||
const fetchCodeContent = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
setFileContent(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch code content:', err);
|
||||
setError('Failed to load code content');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const renderFileIcon = () => {
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
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="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>
|
||||
);
|
||||
case 'video':
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const renderPreview = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isText) {
|
||||
return (
|
||||
<div ref={elementRef} id={id} className="relative w-full">
|
||||
{renderTextContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<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>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
className="max-w-full max-h-[calc(100vh-16rem)] object-contain rounded-lg"
|
||||
onError={() => setError("Failed to load image")}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Only render if we have both url and filename
|
||||
if (!url || !filename) return null;
|
||||
|
||||
// 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}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary btn-sm mt-4"
|
||||
>
|
||||
Download File
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
}
|
Loading…
Reference in a new issue