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 { Asset } from '../../../types/assets';
|
||||||
import type { User } from '../../../types/auth';
|
import type { User } from '../../../types/auth';
|
||||||
import { axiosInstance } from '../../../services/axios';
|
import { axiosInstance } from '../../../services/axios';
|
||||||
|
import { PdfThumbnail } from './PdfThumbnail';
|
||||||
|
|
||||||
interface AssetCardProps {
|
interface AssetCardProps {
|
||||||
asset: Asset;
|
asset: Asset;
|
||||||
@ -223,102 +224,107 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
if (placeholder) placeholder.style.display = 'flex';
|
if (placeholder) placeholder.style.display = 'flex';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : isPdf ? (
|
||||||
|
<PdfThumbnail
|
||||||
<div
|
url={getFullAssetUrl(asset.url)}
|
||||||
id={`fallback-${asset.id}`}
|
title={asset.title}
|
||||||
className="w-full h-full flex items-center justify-center bg-ink-50"
|
fallback={
|
||||||
style={{ display: hasBanner ? 'none' : 'flex' }}
|
<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">
|
||||||
{isPdf ? (
|
<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">
|
||||||
<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">
|
PDF Document
|
||||||
<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>
|
||||||
<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">
|
<div className="space-y-1.5 mt-6 flex-grow pt-1">
|
||||||
PDF Document
|
<div className="h-1 bg-ink-200 rounded w-11/12" />
|
||||||
</div>
|
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||||
<div className="space-y-1.5 mt-6 flex-grow pt-1">
|
<div className="h-1 bg-ink-250 rounded w-10/12" />
|
||||||
<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-200 rounded w-full" />
|
<div className="h-1 bg-ink-200 rounded w-3/4" />
|
||||||
<div className="h-1 bg-ink-250 rounded w-10/12" />
|
</div>
|
||||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
<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">
|
||||||
<div className="h-1 bg-ink-200 rounded w-3/4" />
|
<span>PDF RESOURCE</span>
|
||||||
</div>
|
<FileText className="w-3 h-3 text-red-650" />
|
||||||
<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">
|
</div>
|
||||||
<span>PDF RESOURCE</span>
|
|
||||||
<FileText className="w-3 h-3 text-red-650" />
|
|
||||||
</div>
|
</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
|
||||||
<div className="h-2 bg-ink-0/90 rounded w-3/4 mb-1" />
|
id={`fallback-${asset.id}`}
|
||||||
<div className="space-y-1 flex-grow">
|
className="w-full h-full flex items-center justify-center bg-ink-50"
|
||||||
<div className="h-1 bg-ink-0/60 rounded w-full" />
|
>
|
||||||
<div className="h-1 bg-ink-0/60 rounded w-11/12" />
|
{isPresentation ? (
|
||||||
<div className="h-1 bg-ink-0/60 rounded w-10/12" />
|
<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>
|
<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="flex items-center justify-between text-[7px] text-ink-0/80 font-bold pt-1 border-t border-amber-400">
|
<div className="h-2 bg-ink-0/90 rounded w-3/4 mb-1" />
|
||||||
<span>PRESENTATION</span>
|
<div className="space-y-1 flex-grow">
|
||||||
<span className="font-extrabold uppercase">PPTX</span>
|
<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>
|
</div>
|
||||||
</div>
|
) : isWord ? (
|
||||||
) : 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="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="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">
|
||||||
<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
|
||||||
Word Doc
|
</div>
|
||||||
</div>
|
<div className="space-y-1.5 mt-6 flex-grow pt-1">
|
||||||
<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-11/12" />
|
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||||
<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-250 rounded w-10/12" />
|
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
</div>
|
||||||
</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">
|
||||||
<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>
|
||||||
<span>DOCX RESOURCE</span>
|
<FileText className="w-3 h-3 text-blue-650" />
|
||||||
<FileText className="w-3 h-3 text-blue-650" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : isSpreadsheet ? (
|
||||||
) : 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="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="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">
|
||||||
<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
|
||||||
Spreadsheet
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-3 gap-1 mt-5 flex-grow pt-1">
|
||||||
<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-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 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>
|
<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">
|
||||||
<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>XLSX SHEET</span>
|
<span className="font-extrabold text-emerald-650 text-[6px]">EXCEL</span>
|
||||||
<span className="font-extrabold text-emerald-650 text-[6px]">EXCEL</span>
|
</div>
|
||||||
</div>
|
</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="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="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">
|
||||||
<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
|
||||||
Resource File
|
</div>
|
||||||
</div>
|
<div className="flex-grow flex items-center justify-center pt-4">
|
||||||
<div className="flex-grow flex items-center justify-center pt-4">
|
<File className="w-8 h-8 text-ink-400" />
|
||||||
<File className="w-8 h-8 text-ink-400" />
|
</div>
|
||||||
</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">
|
||||||
<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>BINARY</span>
|
<span className="font-extrabold uppercase text-[6px]">{asset.type.split('/').pop() || 'FILE'}</span>
|
||||||
<span className="font-extrabold uppercase text-[6px]">{asset.type.split('/').pop() || 'FILE'}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 mb-4 flex-grow flex flex-col">
|
<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