import { memo, useEffect, useRef, useState, type RefObject } from "react"; import { AUDIO_RENDITION_NAME, AudioMetadata, ContentNature, ContentObject, ContentObjectStatus, DocAnalyzerProgress, DocProcessorOutputFormat, DocumentMetadata, ImageRenditionFormat, MarkdownRenditionFormat, PDF_RENDITION_NAME, Permission, POSTER_RENDITION_NAME, VideoMetadata, WorkflowExecutionStatus } from "@vertesia/common"; import { Button, Dropdown, MenuItem, Portal, ResizableHandle, ResizablePanel, ResizablePanelGroup, Spinner, useFetch, useToast } from "@vertesia/ui/core"; import { NavLink } from "@vertesia/ui/router"; import { useUserSession } from "@vertesia/ui/session"; import { JSONDisplay, MarkdownRenderer, Progress, XMLViewer } from "@vertesia/ui/widgets"; import { AlertTriangle, Copy, Download, FileSearch, SquarePen } from "lucide-react"; import { useUITranslation } from '../../../../i18n/index.js'; import { MagicPdfView } from "../../../magic-pdf"; import { SimplePdfViewer } from "../../../pdf-viewer"; import { SecureButton } from "../../../permissions/SecureButton.js"; import { getWorkflowStatusColor, getWorkflowStatusName, isPreviewableAsPdf } from "../../../utils/index.js"; import { PropertiesEditorModal } from "./PropertiesEditorModal"; import { TextEditorPanel } from "./TextEditorPanel.js"; import { useObjectText, useOfficePdfConversion, usePdfProcessingStatus } from "./useContentPanelHooks.js"; import { useDownloadFile } from "./useDownloadFile.js"; // Web-supported image formats for browser display const WEB_SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; // Web-supported video formats for browser display const WEB_SUPPORTED_VIDEO_FORMATS = ['video/mp4', 'video/webm']; // Web-supported audio formats for browser display const WEB_SUPPORTED_AUDIO_FORMATS = [ 'audio/mp4', // M4A/AAC (official MIME type) 'audio/m4a', // M4A (alternative MIME type) 'audio/x-m4a', // M4A (legacy MIME type) 'audio/mpeg', // MP3 'audio/ogg', // Ogg Vorbis 'audio/wav', // WAV 'audio/webm', // WebM audio ]; // ----- Type Definitions ----- interface TextActionsProps { object: ContentObject; text: string | undefined; fullText: string | undefined; handleCopyContent: (content: string, type: "text" | "properties") => Promise; textContainerRef: RefObject; isEditing?: boolean; onToggleEdit?: () => void; canEdit?: boolean; } interface TextPanelProps { object: ContentObject; text: string | undefined; isTextCropped: boolean; textContainerRef: RefObject; } interface OfficePdfPreviewPanelProps { pdfRendition?: { content?: { source?: string } }; officePdfUrl?: string; officePdfConverting: boolean; officePdfError?: string; onConvert: () => void; } interface OfficePdfActionsProps { object: ContentObject; pdfRendition?: { name: string; content: { source?: string } }; officePdfUrl?: string; } // ----- Markdown Components Configuration ----- /** Common props for markdown component overrides */ interface MarkdownComponentProps { node?: unknown; children?: React.ReactNode; } /** * Custom markdown components for the content overview. * Handles internal links to store objects and provides consistent styling. */ const createMarkdownComponents = () => ({ a: ({ node, ...props }: MarkdownComponentProps & { href?: string }) => { const href = props.href || ""; if (href.includes("/store/objects/")) { return ( {props.children} ); } return ; }, p: ({ node, ...props }: MarkdownComponentProps) => (

), pre: ({ node, ...props }: MarkdownComponentProps) => (

    ),
    code: ({ node, className, children, ...props }: MarkdownComponentProps & { className?: string }) => {
        const match = /language-(\w+)/.exec(className || "");
        const isInline = !match;
        return (
            
                {children}
            
        );
    },
    h1: ({ node, ...props }: MarkdownComponentProps) => (
        

), h2: ({ node, ...props }: MarkdownComponentProps) => (

), h3: ({ node, ...props }: MarkdownComponentProps) => (

), li: ({ node, ...props }: MarkdownComponentProps) =>
  • , }); /** * Check if an object is in created or processing status. */ function isCreatedOrProcessingStatus(status?: ContentObjectStatus): boolean { return status === ContentObjectStatus.created || status === ContentObjectStatus.processing; } /** * Get the content processor type from object metadata. */ function getContentProcessorType(object: ContentObject): string | undefined { return (object.metadata as DocumentMetadata)?.content_processor?.type; } /** * Check if text content appears to be markdown based on common patterns. */ function looksLikeMarkdown(text: string | undefined): boolean { if (!text) return false; return ( text.includes("\n# ") || text.includes("\n## ") || text.includes("\n### ") || text.includes("\n* ") || text.includes("\n- ") || text.includes("\n+ ") || text.includes("![") || text.includes("](") ); } /** * Helper function to get panel visibility className. * Returns empty string if visible, 'hidden' if not visible. */ function getPanelVisibility(isVisible: boolean): string { return isVisible ? 'h-full overflow-auto' : 'hidden'; } enum PanelView { Text = "text", Image = "image", Video = "video", Audio = "audio", Pdf = "pdf", Transcript = "transcript" } interface ContentOverviewProps { object: ContentObject; loadText?: boolean; refetch?: () => Promise; } export function ContentOverview({ object, loadText, refetch, }: ContentOverviewProps) { const toast = useToast(); const { t } = useUITranslation(); const handleCopyContent = async ( content: string, type: "text" | "properties", ) => { try { await navigator.clipboard.writeText(content); toast({ status: "success", title: t('store.contentCopied', { type: type === "text" ? t('store.contentType') : t('store.properties') }), description: t('store.successfullyCopied', { type }), duration: 2000, }); } catch (err) { console.error(`Failed to copy ${type}:`, err); toast({ status: "error", title: t('store.copyFailed'), description: t('store.failedToCopy', { type }), duration: 5000, }); } }; return ( <> Promise.resolve())} handleCopyContent={handleCopyContent} /> ); } function PropertiesPanel({ object, refetch, handleCopyContent }: { object: ContentObject, refetch: () => Promise, handleCopyContent: (content: string, type: "text" | "properties") => Promise }) { const { t } = useUITranslation(); const [viewCode, setViewCode] = useState(false); const [isPropertiesModalOpen, setPropertiesModalOpen] = useState(false); const handleOpenPropertiesModal = () => { setPropertiesModalOpen(true); }; const handleClosePropertiesModal = () => { setPropertiesModalOpen(false); }; return ( <>
    {object.properties && ( )}
    {object.properties ? (
    ) : (
    {t('store.noPropertiesDefined')}
    )}
    ); } function DataPanel({ object, loadText, handleCopyContent, refetch }: { object: ContentObject, loadText: boolean, handleCopyContent: (content: string, type: "text" | "properties") => Promise, refetch?: () => Promise }) { const { t } = useUITranslation(); const isImage = object?.metadata?.type === ContentNature.Image; const isVideo = object?.metadata?.type === ContentNature.Video; const isAudio = object?.metadata?.type === ContentNature.Audio; const isPdf = object?.content?.type === 'application/pdf'; const isPreviewableAsPdfDoc = object?.content?.type ? isPreviewableAsPdf(object.content.type) : false; const isCreatedOrProcessing = isCreatedOrProcessingStatus(object?.status); const hasTranscript = !!(object.transcript && (isVideo || isAudio)); // Check if PDF rendition exists for Office documents const metadata = object.metadata as DocumentMetadata; const pdfRendition = metadata?.renditions?.find(r => r.name === PDF_RENDITION_NAME); // Determine initial panel view const getInitialView = (): PanelView => { if (isVideo) return PanelView.Video; if (isAudio) return PanelView.Audio; if (isImage) return PanelView.Image; return PanelView.Text; }; const [currentPanel, setCurrentPanel] = useState(getInitialView()); const [hasVisitedPdfPanel, setHasVisitedPdfPanel] = useState(currentPanel === PanelView.Pdf); useEffect(() => { if (currentPanel === PanelView.Pdf) { setHasVisitedPdfPanel(true); } }, [currentPanel]); // Text editing state const [isEditing, setIsEditing] = useState(false); const canEdit = !!( object.content?.source && object.content?.type && !isCreatedOrProcessing && !object.is_locked && object.user_permissions?.can_write !== false && (object.content.type.startsWith('text/') || object.content.type === 'application/json' || object.content.type === 'application/xml') ); // Use custom hooks for text loading, PDF processing, and Office conversion const { fullText, displayText, isLoading: isLoadingText, isCropped: isTextCropped, loadText: reloadText, } = useObjectText(object.id, object.text, loadText); // Only poll while the active panel can actually surface processing progress. const shouldPollProgress = (isPdf || isPreviewableAsPdfDoc) && isCreatedOrProcessing && (currentPanel === PanelView.Text || currentPanel === PanelView.Pdf); const { progress: pdfProgress, status: pdfStatus, outputFormat: pdfOutputFormat, isComplete: processingComplete, } = usePdfProcessingStatus(object.id, shouldPollProgress); // Office document PDF conversion const { pdfUrl: officePdfUrl, isConverting: officePdfConverting, error: officePdfError, triggerConversion: triggerOfficePdfConversion, } = useOfficePdfConversion(object.id, isPreviewableAsPdfDoc); // Load text once processing completes without triggering a full object refetch // (which would flash the page-level loading spinner). useEffect(() => { if (processingComplete && pdfStatus === WorkflowExecutionStatus.COMPLETED) { reloadText(); } }, [processingComplete, pdfStatus, reloadText]); // Show processing panel when workflow is running (for both PDFs and Office documents) const showProcessingPanel = (isPdf || isPreviewableAsPdfDoc) && isCreatedOrProcessing && !processingComplete && pdfStatus === WorkflowExecutionStatus.RUNNING; const showPdfPreviewPanel = currentPanel === PanelView.Pdf && !showProcessingPanel; const showPdfProcessingPanel = showProcessingPanel && (currentPanel === PanelView.Text || currentPanel === PanelView.Pdf); const keepPdfPreviewMounted = hasVisitedPdfPanel && !showProcessingPanel; const textContainerRef = useRef(null); return (
    {isImage && } {isVideo && } {isAudio && } {hasTranscript && } {isPdf && } {isPreviewableAsPdfDoc && ( )}
    {currentPanel === PanelView.Text && !showProcessingPanel && !isEditing && ( setIsEditing(true)} canEdit={canEdit} /> )} {currentPanel === PanelView.Pdf && isPreviewableAsPdfDoc && (pdfRendition || officePdfUrl) && ( )}
    {currentPanel === PanelView.Image && (
    )} {currentPanel === PanelView.Video && (
    )} {currentPanel === PanelView.Audio && (
    )} {hasTranscript && currentPanel === PanelView.Transcript && (
    )} {isPdf && keepPdfPreviewMounted && (
    )} {isPreviewableAsPdfDoc && keepPdfPreviewMounted && (
    )} {showPdfProcessingPanel && (
    )} {currentPanel === PanelView.Text && !showProcessingPanel && !isEditing && isLoadingText && (
    )} {currentPanel === PanelView.Text && !showProcessingPanel && !isEditing && !isLoadingText && (
    )} {isEditing && currentPanel === PanelView.Text && fullText != null && ( setIsEditing(false)} onSaved={() => { setIsEditing(false); reloadText(); refetch?.(); }} /> )}
    ); } function TextActions({ object, text, fullText, handleCopyContent, onToggleEdit, canEdit, }: TextActionsProps) { const { client, project } = useUserSession(); const toast = useToast(); const { t } = useUITranslation(); const content = object.content; const { renderDocument, isDownloading } = useDownloadFile({ client, toast }); const { data: fullProject } = useFetch( () => project ? client.projects.retrieve(project.id) : Promise.resolve(undefined), [project?.id] ); const pdfTemplateObjectId = fullProject?.configuration?.pdf_template_object_id; const isMarkdown = content && content.type && content.type === "text/markdown"; // Get content processor type for file extension detection const contentProcessorType = getContentProcessorType(object); const handleExportDocument = async (format: MarkdownRenditionFormat, useDefaultTemplate?: boolean) => { // Prevent multiple concurrent exports if (isDownloading) return; // Show immediate feedback toast({ status: "info", title: `Preparing ${format.toUpperCase()}`, description: t('store.renderingDocument'), duration: 2000, }); // For branded exports, use the project-configured template if available const templateObjectId = useDefaultTemplate !== false ? pdfTemplateObjectId : undefined; await renderDocument(object.id, { format, title: object.name || "document", useDefaultTemplate, templateObjectId, }); }; const handleExportDocx = () => handleExportDocument(MarkdownRenditionFormat.docx); const handleExportPdf = () => handleExportDocument(MarkdownRenditionFormat.pdf, false); const handleExportBrandedPdf = () => handleExportDocument(MarkdownRenditionFormat.pdf); const handleDownloadText = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!fullText) return; // Determine file extension based on content processor type let ext = "txt"; let mimeType = "text/plain"; if (contentProcessorType === "xml") { ext = "xml"; mimeType = "text/xml"; } else if (contentProcessorType === "markdown" || isMarkdown) { ext = "md"; mimeType = "text/markdown"; } const blob = new Blob([fullText], { type: mimeType }); const url = URL.createObjectURL(blob); const filename = `${object.name || "document"}.${ext}`; // Use the download attribute with an anchor, but avoid triggering navigation const link = document.createElement("a"); link.href = url; link.download = filename; link.style.display = "none"; // Temporarily remove from DOM event flow setTimeout(() => { link.click(); URL.revokeObjectURL(url); }, 0); }; return ( <>
    {fullText && ( <> {canEdit && onToggleEdit && ( )} )} {isDownloading ? ( ) : ( }> {fullText && (
    Download Text
    )} {isMarkdown && text && ( <>
    Export as DOCX
    Export as PDF
    Export as Branded PDF
    )}
    )}
    ); } const TextPanel = memo(({ object, text, isTextCropped, textContainerRef, }: TextPanelProps) => { const { t } = useUITranslation(); const content = object.content; const isCreatedOrProcessing = isCreatedOrProcessingStatus(object?.status); // Check content processor type for XML const contentProcessorType = getContentProcessorType(object); const isXml = contentProcessorType === "xml"; // Check if content type is markdown or plain text const isMarkdownOrText = content && content.type && (content.type === "text/markdown" || content.type === "text/plain"); // Render as markdown if it's markdown/text type OR if text looks like markdown (but not if XML) const shouldRenderAsMarkdown = !isXml && (isMarkdownOrText || looksLikeMarkdown(text)); return ( text ? ( <> {isTextCropped && (
    {t('store.showingFirst128K')}
    )}
    {isXml ? (
    ) : shouldRenderAsMarkdown ? (
    {text}
    ) : (
                                {text}
                            
    )}
    ) :
    {isCreatedOrProcessing ? "Extracting content..." : "No content"}
    ); }); function ImagePanel({ object }: { object: ContentObject }) { const { client } = useUserSession(); const [imageUrl, setImageUrl] = useState(); const content = object.content; const isImage = object.metadata && object.metadata.type === ContentNature.Image; useEffect(() => { if (isImage) { // Reset image URL when object changes setImageUrl(undefined); const loadImage = async () => { const isOriginalWebSupported = content?.type && WEB_SUPPORTED_IMAGE_FORMATS.includes(content.type); try { const rendition = await client.objects.getRendition(object.id, { format: ImageRenditionFormat.jpeg, generate_if_missing: false, sign_url: true, }); if (rendition.status === "found" && rendition.renditions?.length) { // Use rendition URL directly setImageUrl(rendition.renditions[0]); } else if (isOriginalWebSupported) { // Fall back to original file only if web-supported const downloadUrl = await client.files.getDownloadUrl(object.content.source!); setImageUrl(downloadUrl.url); } } catch (error) { // Fall back to original file only if web-supported if (isOriginalWebSupported) { const downloadUrl = await client.files.getDownloadUrl(object.content.source!); setImageUrl(downloadUrl.url); } } }; loadImage(); } }, [object.id, isImage, content?.type, content?.source, client]); return (
    {imageUrl ? ( {object.name} ) : ( )}
    ); } function VideoPanel({ object }: { object: ContentObject }) { const { t } = useUITranslation(); const { client } = useUserSession(); const [videoUrl, setVideoUrl] = useState(); const [posterUrl, setPosterUrl] = useState(); const [isLoading, setIsLoading] = useState(true); const content = object.content; const isVideo = object.metadata?.type === ContentNature.Video; // Check if there are mp4 or webm renditions available in metadata const metadata = object.metadata as VideoMetadata; const renditions = metadata?.renditions || []; // Find mp4 or webm rendition by mime type, preferring mp4 const webRendition = renditions.find(r => r.content.type === 'video/mp4') || renditions.find(r => r.content.type === 'video/webm'); // Check if original file is web-compatible const isOriginalWebSupported = content?.type && WEB_SUPPORTED_VIDEO_FORMATS.includes(content.type); // Get poster const poster = renditions.find(r => r.name === POSTER_RENDITION_NAME); // Reset state when object changes useEffect(() => { setVideoUrl(undefined); setPosterUrl(undefined); setIsLoading(true); }, [object.id]); useEffect(() => { const loadPoster = async () => { if (poster?.content?.source) { try { const response = await client.files.getDownloadUrl(poster.content.source); setPosterUrl(response.url); } catch (error) { console.error("Failed to load poster image", error); } } }; loadPoster(); }, [poster, client]); useEffect(() => { if (isVideo && (webRendition?.content?.source || isOriginalWebSupported)) { const loadVideoUrl = async () => { try { let downloadUrl; if (webRendition?.content?.source) { // Use rendition if available downloadUrl = await client.files.getDownloadUrl(webRendition.content.source); } else if (isOriginalWebSupported && content?.source) { // Fall back to original file if web-supported downloadUrl = await client.files.getDownloadUrl(content.source); } if (downloadUrl) { setVideoUrl(downloadUrl.url); } } catch (error) { console.error("Failed to get video URL", error); } finally { setIsLoading(false); } }; loadVideoUrl(); } else { setIsLoading(false); } }, [isVideo, webRendition, isOriginalWebSupported, content?.source, client]); return (
    {!webRendition && !isOriginalWebSupported ? (

    {t('store.noVideoRendition')}

    {t('store.videoFormatRequired')}

    ) : isLoading ? (
    ) : videoUrl ? ( ) : (
    Failed to load video
    )}
    ); } function AudioPanel({ object }: { object: ContentObject }) { const { t } = useUITranslation(); const { client } = useUserSession(); const [audioUrl, setAudioUrl] = useState(); const [isLoading, setIsLoading] = useState(true); const content = object.content; const isAudio = object.metadata?.type === ContentNature.Audio; // Check if there are audio renditions available in metadata const metadata = object.metadata as AudioMetadata; const renditions = metadata?.renditions || []; // Find audio rendition by name (AUDIO_RENDITION_NAME = "Audio") const audioRendition = renditions.find(r => r.name === AUDIO_RENDITION_NAME); // Check if original file is web-compatible const isOriginalWebSupported = content?.type && WEB_SUPPORTED_AUDIO_FORMATS.includes(content.type); // Reset state when object changes useEffect(() => { setAudioUrl(undefined); setIsLoading(true); }, [object.id]); useEffect(() => { if (isAudio && (audioRendition?.content?.source || isOriginalWebSupported)) { const loadAudioUrl = async () => { try { let downloadUrl; if (audioRendition?.content?.source) { // Use rendition if available downloadUrl = await client.files.getDownloadUrl(audioRendition.content.source); } else if (isOriginalWebSupported && content?.source) { // Fall back to original file if web-supported downloadUrl = await client.files.getDownloadUrl(content.source); } if (downloadUrl) { setAudioUrl(downloadUrl.url); } } catch (error) { console.error("Failed to get audio URL", error); } finally { setIsLoading(false); } }; loadAudioUrl(); } else { setIsLoading(false); } }, [isAudio, audioRendition, isOriginalWebSupported, content?.source, client]); return (
    {!audioRendition && !isOriginalWebSupported ? (

    {t('store.noAudioRendition')}

    {t('store.audioFormatRequired')}

    ) : isLoading ? (
    ) : audioUrl ? (
    {metadata?.duration && (
    Duration: {formatDuration(metadata.duration)}
    )}
    ) : (
    Failed to load audio
    )}
    ); } function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; } function TranscriptPanel({ object, handleCopyContent }: { object: ContentObject, handleCopyContent: (content: string, type: "text" | "properties") => Promise }) { const { t } = useUITranslation(); const transcript = object.transcript; const transcriptText = transcript?.text; const segments = transcript?.segments; // Build full text from segments if text is not available const fullText = transcriptText || (segments ? segments.map(s => s.text).join(' ') : ''); const formatTimestamp = (seconds: number): string => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; }; return (
    {fullText && ( )}
    {segments && segments.length > 0 ? (
    {segments.map((segment, idx) => (
    {formatTimestamp(segment.start)} {segment.end && ` - ${formatTimestamp(segment.end)}`} {segment.text}
    ))}
    ) : transcriptText ? (
                            {transcriptText}
                        
    ) : (
    {t('store.noTranscriptAvailable')}
    )}
    ); } function PdfActions({ object }: { object: ContentObject }) { const [isPdfPreviewOpen, setPdfPreviewOpen] = useState(false); // Check if PDF has been processed (content_processor.type is xml or markdown) const contentProcessorType = getContentProcessorType(object); const hasPdfAnalysis = contentProcessorType === "xml" || contentProcessorType === "markdown"; if (!hasPdfAnalysis) return null; return ( <> {isPdfPreviewOpen && ( setPdfPreviewOpen(false)} /> )} ); } function OfficePdfActions({ object, pdfRendition, officePdfUrl, }: OfficePdfActionsProps) { const { client } = useUserSession(); const toast = useToast(); const { t } = useUITranslation(); const [isDownloading, setIsDownloading] = useState(false); const handleDownloadPdf = async () => { setIsDownloading(true); try { let downloadUrl = officePdfUrl; // If we have a rendition source but no signed URL yet, get a signed URL if (!downloadUrl && pdfRendition?.content?.source) { const response = await client.files.getDownloadUrl( pdfRendition.content.source, `${object.name || 'document'}.pdf`, 'attachment' ); downloadUrl = response.url; } if (downloadUrl) { // Open in new tab - browser will handle as download due to content-disposition window.open(downloadUrl, '_blank'); } } catch (err) { console.error('Failed to download PDF:', err); toast({ status: 'error', title: t('store.downloadFailed'), description: t('store.failedToDownloadPdf'), duration: 5000, }); } finally { setIsDownloading(false); } }; return (
    ); } function PdfPreviewPanel({ object }: { object: ContentObject }) { return (
    ); } /** * Panel for displaying Office documents converted to PDF. * Handles the various states: converting, error, showing PDF. */ function OfficePdfPreviewPanel({ pdfRendition, officePdfUrl, officePdfConverting, officePdfError, onConvert, }: OfficePdfPreviewPanelProps) { const { t } = useUITranslation(); if (officePdfConverting) { return (
    {t('store.convertingToPdf')}
    ); } if (officePdfError) { return (
    {officePdfError}
    ); } if (pdfRendition?.content?.source) { return (
    ); } if (officePdfUrl) { return (
    ); } return (
    ); } function PdfProcessingPanel({ progress, status, outputFormat }: { progress?: DocAnalyzerProgress, status?: WorkflowExecutionStatus, outputFormat?: DocProcessorOutputFormat }) { const { t } = useUITranslation(); const statusColor = getWorkflowStatusColor(status); const statusName = getWorkflowStatusName(status); // Show detailed progress (tables, images, visuals) for XML processing const isXmlProcessing = outputFormat === "xml"; // Ensure percent is a valid number (handle undefined and NaN from division by zero) const percent = progress?.percent != null && !isNaN(progress.percent) ? progress.percent : 0; return (
    {progress && (
    {isXmlProcessing && ( <> )}
    Progress: {percent}% {statusName} {progress.started_at && ( <> {((Date.now() - progress.started_at) / 1000).toFixed(0)} sec. elapsed )}
    )} {!progress && (
    {t('store.loadingProcessingStatus')}
    )}
    ); } function ProgressLine({ name, progress }: { name: string, progress: { total: number; processed: number } }) { return (
    {name}: {progress.processed} of {progress.total}
    ); }