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 <noreply@anthropic.com>
This commit is contained in:
michael 2026-01-25 08:56:23 -06:00
parent 0bfe28af59
commit d97be02b0b
5 changed files with 100 additions and 5 deletions

View file

@ -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(

View file

@ -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(

View file

@ -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<{
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">
Proof Preview
</h2>
<ProofPreview
previewUrl={selectedVersion.proofPreviewUrl}
<ProofPreview
previewUrl={selectedVersion.proofPreviewUrl}
fileName={`${proof.proofName} - V${selectedVersion.version}`}
pdfPages={pdfPages}
/>
</div>

View file

@ -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<PDFPage[] | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<PDFPage[]> {
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<void> {
return this.fetch<void>(`/proofs/${id}`, {
method: 'DELETE',