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:
parent
0bfe28af59
commit
d97be02b0b
5 changed files with 100 additions and 5 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
37
frontend/hooks/usePdfPages.ts
Normal file
37
frontend/hooks/usePdfPages.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue