Feat: Add dynamic client-side PDF first-page thumbnail rendering using PDF.js
This commit is contained in:
parent
ea7d688f7c
commit
33e6a658f4
@ -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">
|
||||
|
||||
129
Channel-Frontend/src/features/assets/components/PdfThumbnail.tsx
Normal file
129
Channel-Frontend/src/features/assets/components/PdfThumbnail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user