From d97be02b0bb4841aa9d713c4570f2d20985cac8c Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 25 Jan 2026 08:56:23 -0600 Subject: [PATCH] Add PDF preview support with on-demand rasterization - Backend: Generate PDF thumbnail from first rasterized page on upload - Backend: Add /files/{storage_key}/pages endpoint for PDF rasterization - Frontend: Add getPdfPages() method to apiService - Frontend: Create usePdfPages hook for on-demand PDF page loading - Frontend: Pass pdfPages prop to ProofPreview in Campaigns view This fixes the issue where PDF uploads showed no visual preview in results. Co-Authored-By: Claude Opus 4.5 --- backend/app/api/routes.py | 35 +++++++++++++++++++++++++++++ backend/app/websocket/handlers.py | 9 ++++++-- frontend/components/Campaigns.tsx | 9 ++++++-- frontend/hooks/usePdfPages.ts | 37 +++++++++++++++++++++++++++++++ frontend/services/apiService.ts | 15 ++++++++++++- 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 frontend/hooks/usePdfPages.ts diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 9aeb3f4..2a46c4c 100755 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,4 +1,5 @@ """REST API routes for campaigns, proofs, and audit items.""" +import base64 import uuid from typing import Optional @@ -35,6 +36,7 @@ from app.repositories import ( ) from app.services.storage_service import storage_service from app.services.email_service import email_service +from app.services.pdf_service import pdf_service router = APIRouter() @@ -674,6 +676,39 @@ async def get_file( ) +@router.get("/files/{storage_key:path}/pages") +async def get_pdf_pages( + storage_key: str, + max_pages: int = Query(10, ge=1, le=50), + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Rasterize a stored PDF and return pages as data URLs.""" + if not storage_key.lower().endswith('.pdf'): + raise HTTPException(status_code=400, detail="File is not a PDF") + + file_data = await storage_service.get_file(storage_key) + if file_data is None: + raise HTTPException(status_code=404, detail="File not found") + + try: + pages = pdf_service.rasterize(file_data, max_pages=max_pages) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return { + "pages": [ + { + "page": i + 1, + "data_url": f"data:image/png;base64,{base64.b64encode(png_data).decode('utf-8')}", + "width": width, + "height": height, + } + for i, (png_data, width, height) in enumerate(pages) + ] + } + + # Support email endpoint (public - no auth required for login page access) @router.post("/support/email") async def send_support_email( diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index b007d33..2a71cd9 100755 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -150,8 +150,13 @@ async def handle_analyze_message( # Generate thumbnail for images up to 10MB (data URLs are ~33% larger than binary) thumbnail_url = None - if len(file_data) < 10000000 and file_type.startswith('image/'): - thumbnail_url = await storage_service.generate_thumbnail_data_url(file_data, file_type) + if len(file_data) < 10000000: + if file_type.startswith('image/'): + thumbnail_url = await storage_service.generate_thumbnail_data_url(file_data, file_type) + elif file_type == 'application/pdf' and pdf_pages: + # Use first rasterized page as thumbnail + first_page_data, _, _ = pdf_pages[0] + thumbnail_url = await storage_service.generate_thumbnail_data_url(first_page_data, 'image/png') # Save proof and version proof, version = await proof_repo.add_version_with_review( diff --git a/frontend/components/Campaigns.tsx b/frontend/components/Campaigns.tsx index 617f4f9..91ea218 100755 --- a/frontend/components/Campaigns.tsx +++ b/frontend/components/Campaigns.tsx @@ -25,6 +25,7 @@ import { PDFReport } from './PDFReport'; import { ExportIcon } from './icons/ExportIcon'; import { XIcon } from './icons/XIcon'; import apiService from '../services/apiService'; +import { usePdfPages } from '../hooks/usePdfPages'; const formatDate = (isoDateString: string): string => { const date = new Date(isoDateString); @@ -1300,6 +1301,9 @@ const ProofDetailView: React.FC<{ const versions = proof.versions || []; const selectedVersion = versions[selectedVersionIndex]; + // Load PDF pages on-demand for PDF files + const { pdfPages } = usePdfPages(selectedVersion?.fileStorageKey); + const handleFlagSubmitWrapper = (agentName: string, comments: string) => { onFlagSubmit({ campaignName, @@ -1481,9 +1485,10 @@ const ProofDetailView: React.FC<{

Proof Preview

- diff --git a/frontend/hooks/usePdfPages.ts b/frontend/hooks/usePdfPages.ts new file mode 100644 index 0000000..dbb2c74 --- /dev/null +++ b/frontend/hooks/usePdfPages.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react'; +import type { PDFPage } from '../types'; +import apiService from '../services/apiService'; + +export function usePdfPages(storageKey: string | undefined | null) { + const [pdfPages, setPdfPages] = useState(undefined); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!storageKey || !storageKey.toLowerCase().endsWith('.pdf')) { + setPdfPages(undefined); + return; + } + + let cancelled = false; + const loadPages = async () => { + setLoading(true); + setError(null); + try { + const pages = await apiService.getPdfPages(storageKey); + if (!cancelled) setPdfPages(pages); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load PDF'); + setPdfPages(undefined); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + loadPages(); + return () => { cancelled = true; }; + }, [storageKey]); + + return { pdfPages, loading, error }; +} diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index 1220945..86da899 100755 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -1,6 +1,6 @@ import { IPublicClientApplication } from '@azure/msal-browser'; import { getAccessToken } from './authService'; -import type { AgentReview, FlaggedItem, ResolvedItem, ErrorItem } from '../types'; +import type { AgentReview, FlaggedItem, ResolvedItem, ErrorItem, PDFPage } from '../types'; const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; @@ -193,6 +193,19 @@ class ApiService { return new File([blob], filename, { type: blob.type }); } + async getPdfPages(storageKey: string, maxPages: number = 10): Promise { + const headers = await this.getHeaders(); + const response = await fetch( + `${API_URL}/api/files/${storageKey}/pages?max_pages=${maxPages}`, + { headers } + ); + if (!response.ok) { + throw new Error(`Failed to fetch PDF pages: HTTP ${response.status}`); + } + const data = await response.json(); + return data.pages as PDFPage[]; + } + async deleteProof(id: string): Promise { return this.fetch(`/proofs/${id}`, { method: 'DELETE',