import { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { documentPreviewFallbackUrl, getDocumentContentSnippet, getDocumentPreviewStatus, } from '@/lib/client/api/documents'; import { getInMemoryDocumentPreviewUrl, getPersistedDocumentPreviewUrl, primeDocumentPreviewCache, setInMemoryDocumentPreviewUrl, } from '@/lib/client/cache/previews'; import { formatDocumentSize } from './formatSize'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; interface DocumentPreviewProps { doc: DocumentListDocument; } const MAX_TEXT_PREVIEW_CACHE = 100; const textPreviewCache = new Map(); /** Read from cache and promote entry to most-recently-used. */ function textPreviewCacheGet(key: string): string | undefined { const value = textPreviewCache.get(key); if (value !== undefined) { // Re-insert to move to end (most-recently-used) textPreviewCache.delete(key); textPreviewCache.set(key, value); } return value; } /** Write to cache, evicting the least-recently-used entry when over the cap. */ function textPreviewCacheSet(key: string, value: string): void { // If the key already exists, delete first so re-insertion moves it to the end if (textPreviewCache.has(key)) { textPreviewCache.delete(key); } textPreviewCache.set(key, value); if (textPreviewCache.size > MAX_TEXT_PREVIEW_CACHE) { // Map keys iterate in insertion order; first key is the LRU entry const oldest = textPreviewCache.keys().next().value; if (oldest !== undefined) textPreviewCache.delete(oldest); } } export function DocumentPreview({ doc }: DocumentPreviewProps) { const isPDF = doc.type === 'pdf'; const isEPUB = doc.type === 'epub'; const isHTML = doc.type === 'html'; const lowerName = doc.name.toLowerCase(); const isTxtFile = isHTML && lowerName.endsWith('.txt'); const isMarkdownFile = isHTML && (lowerName.endsWith('.md') || lowerName.endsWith('.markdown') || lowerName.endsWith('.mdown') || lowerName.endsWith('.mkd')); const containerRef = useRef(null); const imageRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [imagePreview, setImagePreview] = useState(null); const [isImageReady, setIsImageReady] = useState(false); const [textPreview, setTextPreview] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const previewKey = useMemo(() => `${doc.type}:${doc.id}`, [doc.id, doc.type]); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry?.isIntersecting) { setIsVisible(true); } }, { rootMargin: '200px' }, ); observer.observe(el); return () => observer.disconnect(); }, []); useEffect(() => { if (!isVisible) return; const cachedImage = getInMemoryDocumentPreviewUrl(previewKey); if (cachedImage) { setImagePreview(cachedImage); setTextPreview(null); return; } const cachedText = textPreviewCacheGet(previewKey); if (cachedText) { setTextPreview(cachedText); setImagePreview(null); return; } let cancelled = false; const controller = new AbortController(); const run = async () => { setIsGenerating(true); try { if (doc.type === 'pdf' || doc.type === 'epub') { const persistedUrl = await getPersistedDocumentPreviewUrl( doc.id, Number(doc.lastModified), previewKey, ); if (!cancelled && persistedUrl) { setImagePreview(persistedUrl); setTextPreview(null); return; } let attempt = 0; while (!cancelled && attempt < 12) { const status = await getDocumentPreviewStatus(doc.id, { signal: controller.signal }); if (cancelled) return; if (status.kind === 'ready') { const primedUrl = await primeDocumentPreviewCache( doc.id, Number(doc.lastModified), previewKey, { signal: controller.signal }, ).catch(() => null); if (cancelled) return; if (primedUrl) { setImagePreview(primedUrl); setTextPreview(null); return; } const fallbackUrl = status.fallbackUrl || documentPreviewFallbackUrl(doc.id); setInMemoryDocumentPreviewUrl(previewKey, fallbackUrl); setImagePreview(fallbackUrl); setTextPreview(null); return; } if (status.status === 'failed') { return; } const waitMs = Math.max( 400, Math.min(6000, Number.isFinite(status.retryAfterMs) ? status.retryAfterMs : 1500), ); await new Promise((resolve) => { const timer = setTimeout(resolve, waitMs); controller.signal.addEventListener( 'abort', () => { clearTimeout(timer); resolve(); }, { once: true }, ); }); attempt += 1; } return; } if (doc.type === 'html') { const snippet = await getDocumentContentSnippet(doc.id, { maxChars: 1600, maxBytes: 128 * 1024, signal: controller.signal, }); if (cancelled) return; textPreviewCacheSet(previewKey, snippet); setTextPreview(snippet); setImagePreview(null); return; } } catch { // fall back to icon } finally { if (!cancelled) { setIsGenerating(false); } } }; run(); return () => { cancelled = true; controller.abort(); }; }, [doc.id, doc.lastModified, doc.type, isVisible, previewKey]); useEffect(() => { setIsImageReady(false); }, [imagePreview]); // Cached blob/http sources can already be decoded before React's onLoad handler // runs on remount, leaving opacity at 0. Promote already-complete images. useEffect(() => { if (!imagePreview) return; const img = imageRef.current; if (!img) return; if (img.complete && img.naturalWidth > 0) { setIsImageReady(true); } }, [imagePreview]); const gradientClass = isPDF ? 'from-red-500/80 via-red-400/60 to-red-600/80' : isEPUB ? 'from-blue-500/80 via-blue-400/60 to-blue-600/80' : isHTML ? 'from-violet-500/80 via-violet-400/60 to-violet-600/80' : 'from-slate-500/80 via-slate-400/60 to-slate-600/80'; const Icon = isPDF ? PDFIcon : isEPUB ? EPUBIcon : FileIcon; const typeLabel = isPDF ? 'PDF' : isEPUB ? 'EPUB' : isHTML ? isTxtFile ? 'TXT' : isMarkdownFile ? 'MD' : 'TEXT' : 'FILE'; return (
{imagePreview ? ( <>
{!isImageReady ? (
{typeLabel}
) : null} {/* eslint-disable-next-line @next/next/no-img-element */} {`${doc.name} { setIsImageReady(true); }} onError={() => { if (!imagePreview) return; setIsImageReady(false); const fallback = documentPreviewFallbackUrl(doc.id); if (imagePreview === fallback) return; setInMemoryDocumentPreviewUrl(previewKey, fallback); setImagePreview(fallback); void primeDocumentPreviewCache( doc.id, Number(doc.lastModified), previewKey, ).catch(() => { }); }} /> {isImageReady ?
: null} ) : textPreview ? ( <>
{isTxtFile ? (
                  {textPreview}
                
) : (

, h1: (props) =>

, h2: (props) =>

, h3: (props) =>

, h4: (props) =>

, h5: (props) =>

, h6: (props) =>
, ul: (props) =>
    , ol: (props) =>
      , li: (props) =>
    1. , a: ({ children }) => {children}, img: () => null, blockquote: (props) => (
      ), code: (props) => ( ), pre: (props) => (
                            ),
                          }}
                        >
                          {textPreview}
                        
                      
)}
) : ( <>
{typeLabel}
)}
{isGenerating ? '…' : `${typeLabel} • ${formatDocumentSize(doc.size)}`}
); }