Feat: Add dynamic client-side PDF first-page thumbnail rendering using PDF.js

This commit is contained in:
kenilkb 2026-07-16 09:25:11 +05:30
parent ea7d688f7c
commit 33e6a658f4
2 changed files with 221 additions and 86 deletions

View File

@ -17,6 +17,7 @@ import {
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
import { axiosInstance } from '../../../services/axios';
import { PdfThumbnail } from './PdfThumbnail';
interface AssetCardProps {
asset: Asset;
@ -223,102 +224,107 @@ export const AssetCard: React.FC<AssetCardProps> = ({
if (placeholder) placeholder.style.display = 'flex';
}}
/>
) : null}
<div
id={`fallback-${asset.id}`}
className="w-full h-full flex items-center justify-center bg-ink-50"
style={{ display: hasBanner ? 'none' : 'flex' }}
>
{isPdf ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative bg-red-500/[0.03] hover:bg-red-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-red-500/20 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
PDF Document
</div>
<div className="space-y-1.5 mt-6 flex-grow pt-1">
<div className="h-1 bg-ink-200 rounded w-11/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-250 rounded w-10/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-200 rounded w-3/4" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>PDF RESOURCE</span>
<FileText className="w-3 h-3 text-red-650" />
) : isPdf ? (
<PdfThumbnail
url={getFullAssetUrl(asset.url)}
title={asset.title}
fallback={
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative bg-red-500/[0.03] hover:bg-red-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-red-500/20 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
PDF Document
</div>
<div className="space-y-1.5 mt-6 flex-grow pt-1">
<div className="h-1 bg-ink-200 rounded w-11/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-250 rounded w-10/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-200 rounded w-3/4" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>PDF RESOURCE</span>
<FileText className="w-3 h-3 text-red-650" />
</div>
</div>
</div>
</div>
) : isPresentation ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-amber-500/[0.03] hover:bg-amber-500/[0.06] transition-colors">
<div className="w-[125px] h-[90px] bg-amber-500 border border-amber-600 shadow-md rounded-lg flex flex-col justify-between p-2.5 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="h-2 bg-ink-0/90 rounded w-3/4 mb-1" />
<div className="space-y-1 flex-grow">
<div className="h-1 bg-ink-0/60 rounded w-full" />
<div className="h-1 bg-ink-0/60 rounded w-11/12" />
<div className="h-1 bg-ink-0/60 rounded w-10/12" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-0/80 font-bold pt-1 border-t border-amber-400">
<span>PRESENTATION</span>
<span className="font-extrabold uppercase">PPTX</span>
}
/>
) : (
<div
id={`fallback-${asset.id}`}
className="w-full h-full flex items-center justify-center bg-ink-50"
>
{isPresentation ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-amber-500/[0.03] hover:bg-amber-500/[0.06] transition-colors">
<div className="w-[125px] h-[90px] bg-amber-500 border border-amber-600 shadow-md rounded-lg flex flex-col justify-between p-2.5 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="h-2 bg-ink-0/90 rounded w-3/4 mb-1" />
<div className="space-y-1 flex-grow">
<div className="h-1 bg-ink-0/60 rounded w-full" />
<div className="h-1 bg-ink-0/60 rounded w-11/12" />
<div className="h-1 bg-ink-0/60 rounded w-10/12" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-0/80 font-bold pt-1 border-t border-amber-400">
<span>PRESENTATION</span>
<span className="font-extrabold uppercase">PPTX</span>
</div>
</div>
</div>
</div>
) : isWord ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-blue-500/[0.03] hover:bg-blue-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-blue-500/20 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-blue-600 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
Word Doc
</div>
<div className="space-y-1.5 mt-6 flex-grow pt-1">
<div className="h-1 bg-ink-200 rounded w-11/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-250 rounded w-10/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>DOCX RESOURCE</span>
<FileText className="w-3 h-3 text-blue-650" />
) : isWord ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-blue-500/[0.03] hover:bg-blue-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-blue-500/20 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-blue-600 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
Word Doc
</div>
<div className="space-y-1.5 mt-6 flex-grow pt-1">
<div className="h-1 bg-ink-200 rounded w-11/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-250 rounded w-10/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>DOCX RESOURCE</span>
<FileText className="w-3 h-3 text-blue-650" />
</div>
</div>
</div>
</div>
) : isSpreadsheet ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-emerald-500/[0.03] hover:bg-emerald-500/[0.06] transition-colors">
<div className="w-[115px] h-[90px] bg-ink-0 border border-emerald-500/20 shadow-md rounded-lg flex flex-col justify-between p-2 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-emerald-650 text-ink-0 py-1 text-[7px] font-extrabold uppercase text-center tracking-wider font-sans">
Spreadsheet
</div>
<div className="grid grid-cols-3 gap-1 mt-5 flex-grow pt-1">
<div className="h-2 bg-emerald-50 border border-emerald-100 rounded" />
<div className="h-2 bg-emerald-50 border border-emerald-100 rounded" />
<div className="h-2 bg-emerald-50 border border-emerald-100 rounded" />
<div className="h-2 bg-ink-50 border border-ink-100 rounded" />
<div className="h-2 bg-ink-50 border border-ink-100 rounded" />
<div className="h-2 bg-ink-50 border border-ink-100 rounded" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>XLSX SHEET</span>
<span className="font-extrabold text-emerald-650 text-[6px]">EXCEL</span>
) : isSpreadsheet ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-emerald-500/[0.03] hover:bg-emerald-500/[0.06] transition-colors">
<div className="w-[115px] h-[90px] bg-ink-0 border border-emerald-500/20 shadow-md rounded-lg flex flex-col justify-between p-2 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-emerald-650 text-ink-0 py-1 text-[7px] font-extrabold uppercase text-center tracking-wider font-sans">
Spreadsheet
</div>
<div className="grid grid-cols-3 gap-1 mt-5 flex-grow pt-1">
<div className="h-2 bg-emerald-50 border border-emerald-100 rounded" />
<div className="h-2 bg-emerald-50 border border-emerald-100 rounded" />
<div className="h-2 bg-emerald-50 border border-emerald-100 rounded" />
<div className="h-2 bg-ink-50 border border-ink-100 rounded" />
<div className="h-2 bg-ink-50 border border-ink-100 rounded" />
<div className="h-2 bg-ink-50 border border-ink-100 rounded" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>XLSX SHEET</span>
<span className="font-extrabold text-emerald-650 text-[6px]">EXCEL</span>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-ink-500/[0.03] hover:bg-ink-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-ink-200 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-ink-700 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
Resource File
</div>
<div className="flex-grow flex items-center justify-center pt-4">
<File className="w-8 h-8 text-ink-400" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>BINARY</span>
<span className="font-extrabold uppercase text-[6px]">{asset.type.split('/').pop() || 'FILE'}</span>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-ink-500/[0.03] hover:bg-ink-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-ink-200 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-ink-700 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
Resource File
</div>
<div className="flex-grow flex items-center justify-center pt-4">
<File className="w-8 h-8 text-ink-400" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>BINARY</span>
<span className="font-extrabold uppercase text-[6px]">{asset.type.split('/').pop() || 'FILE'}</span>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="space-y-1 mb-4 flex-grow flex flex-col">

View File

@ -0,0 +1,129 @@
import React, { useState, useEffect, useRef } from 'react';
interface PdfThumbnailProps {
url: string;
title: string;
fallback: React.ReactNode;
}
// Simple in-memory cache for rendered thumbnails to avoid reprocessing the same PDFs during a session
const thumbnailCache: Record<string, string> = {};
export const PdfThumbnail: React.FC<PdfThumbnailProps> = ({ url, title, fallback }) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(thumbnailCache[url] || null);
const [error, setError] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(!thumbnailCache[url]);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (thumbnailUrl) {
setLoading(false);
return;
}
let isMounted = true;
const loadPdfAndRender = async () => {
try {
// 1. Ensure PDF.js script is loaded dynamically
if (!(window as any).pdfjsLib) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load PDF.js library'));
document.body.appendChild(script);
});
}
const pdfjs = (window as any).pdfjsLib;
if (!pdfjs) throw new Error('PDF.js lib not available');
// Configure PDF.js Worker
pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
// 2. Fetch and load the PDF document
const loadingTask = pdfjs.getDocument(url);
const pdf = await loadingTask.promise;
if (!isMounted) return;
// 3. Load the first page of the PDF
const page = await pdf.getPage(1);
if (!isMounted) return;
// Create a temporary canvas if not in DOM yet
const canvas = canvasRef.current || document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not get 2D context');
// We want a thumbnail width of around 220px to fit the card well
const originalViewport = page.getViewport({ scale: 1.0 });
const scale = 220 / originalViewport.width;
const viewport = page.getViewport({ scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
// Render PDF page to canvas
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext).promise;
if (!isMounted) return;
// Convert canvas rendering to a base64 image URL for high performance image display
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
thumbnailCache[url] = dataUrl;
if (isMounted) {
setThumbnailUrl(dataUrl);
setLoading(false);
}
} catch (err) {
console.error('[PdfThumbnail] Failed to render first page preview:', err);
if (isMounted) {
setError(true);
setLoading(false);
}
}
};
loadPdfAndRender();
return () => {
isMounted = false;
};
}, [url]);
if (error) {
return <>{fallback}</>;
}
return (
<div className="w-full h-full flex items-center justify-center relative overflow-hidden bg-ink-50">
{/* Hidden canvas for offscreen rendering */}
<canvas ref={canvasRef} className="hidden" />
{loading ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 space-y-2">
<div className="w-5 h-5 rounded-full border-2 border-ink-400 border-t-transparent animate-spin" />
<span className="text-[10px] text-ink-400 font-medium">Generating preview...</span>
</div>
) : thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
fallback
)}
</div>
);
};