F015 - Multi-format Export System
Objective
Enable authors to export their analysis reports as PDF or HTML files for offline viewing, sharing, and archival purposes.
Quick Implementation
Using NextSaaS Components
- API route patterns for file generation
- Response streaming for large files
- Authentication middleware
- Error handling utilities
New Requirements
- PDF generation library
- HTML templating
- File streaming
- Export tracking
MVP Implementation
1. Package Installation
# In packages/export directory
npm install @react-pdf/renderer puppeteer-core chrome-aws-lambda2. Database Schema
-- Export tracking
CREATE TABLE analyzer.report_exports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
report_id UUID REFERENCES analyzer.analysis_reports(id),
user_id UUID REFERENCES auth.users(id),
format VARCHAR(20) NOT NULL, -- 'pdf', 'html'
file_size INTEGER,
download_url TEXT,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_report_exports_user_id ON analyzer.report_exports(user_id);
CREATE INDEX idx_report_exports_expires_at ON analyzer.report_exports(expires_at);3. PDF Export Service
// packages/export/src/services/pdf-exporter.ts
import { Document, Page, Text, View, StyleSheet, pdf } from '@react-pdf/renderer'
import { createClient } from '@mystoryflow/database/server'
// PDF Styles
const styles = StyleSheet.create({
page: {
padding: 40,
fontFamily: 'Helvetica'
},
header: {
marginBottom: 20,
textAlign: 'center'
},
title: {
fontSize: 24,
marginBottom: 10,
fontWeight: 'bold'
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 5
},
section: {
marginTop: 20,
marginBottom: 15
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 10,
color: '#333'
},
paragraph: {
fontSize: 11,
lineHeight: 1.6,
marginBottom: 10,
textAlign: 'justify'
},
scoreBox: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20
},
bigScore: {
fontSize: 48,
fontWeight: 'bold',
marginRight: 20
},
list: {
marginLeft: 20,
marginBottom: 10
},
listItem: {
fontSize: 11,
marginBottom: 5
}
})
// PDF Document Component
const AnalysisReportPDF = ({ data }: { data: any }) => (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Manuscript Analysis Report</Text>
<Text style={styles.subtitle}>{data.manuscript.title}</Text>
<Text style={{ fontSize: 10, color: '#999' }}>
{data.manuscript.wordCount.toLocaleString()} words • {data.manuscript.genre} • {new Date().toLocaleDateString()}
</Text>
</View>
{/* Executive Summary */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Executive Summary</Text>
<View style={styles.scoreBox}>
<Text style={styles.bigScore}>{data.overallScore}%</Text>
<View>
<Text style={{ fontSize: 14, fontWeight: 'bold' }}>
{data.overallScore >= 85 ? 'Publication Ready' :
data.overallScore >= 70 ? 'Needs Revision' :
'Major Work Needed'}
</Text>
<Text style={{ fontSize: 10, color: '#666' }}>
Overall Manuscript Score
</Text>
</View>
</View>
<Text style={styles.paragraph}>
Your {data.manuscript.genre} manuscript demonstrates several strengths
while also presenting opportunities for improvement. This comprehensive
analysis evaluates your work across multiple dimensions to provide
actionable insights for enhancement.
</Text>
</View>
{/* Strengths */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Key Strengths</Text>
<View style={styles.list}>
{data.topStrengths.map((strength: string, i: number) => (
<Text key={i} style={styles.listItem}>• {strength}</Text>
))}
</View>
</View>
{/* Areas for Improvement */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Areas for Improvement</Text>
<View style={styles.list}>
{data.topWeaknesses.map((weakness: string, i: number) => (
<Text key={i} style={styles.listItem}>• {weakness}</Text>
))}
</View>
</View>
{/* Category Scores */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Category Breakdown</Text>
{Object.entries(data.categoryScores).map(([category, score]) => (
<View key={category} style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>
{category.replace(/_/g, ' ').toUpperCase()}: {score}%
</Text>
</View>
))}
</View>
</Page>
{/* Additional pages for detailed analysis */}
<Page size="A4" style={styles.page}>
<Text style={styles.sectionTitle}>Detailed Analysis</Text>
{/* Add more detailed sections here */}
<View style={styles.section}>
<Text style={styles.paragraph}>
{/* Detailed analysis content */}
</Text>
</View>
</Page>
</Document>
)
export class PDFExporter {
private supabase = getSupabaseBrowserClient()
async exportToPDF(reportId: string, userId: string): Promise<Buffer> {
// Get report data
const { data: report } = await this.supabase
.from('analyzer.analysis_reports')
.select('*')
.eq('id', reportId)
.single()
if (!report) throw new Error('Report not found')
// Generate PDF
const doc = <AnalysisReportPDF data={report.report_data} />
const pdfBuffer = await pdf(doc).toBuffer()
// Track export
await this.trackExport(reportId, userId, 'pdf', pdfBuffer.length)
return pdfBuffer
}
private async trackExport(
reportId: string,
userId: string,
format: string,
fileSize: number
): Promise<void> {
await this.supabase
.from('analyzer.report_exports')
.insert({
report_id: reportId,
user_id: userId,
format,
file_size: fileSize
})
}
}4. HTML Export Service
// packages/export/src/services/html-exporter.ts
export class HTMLExporter {
private supabase = getSupabaseBrowserClient()
async exportToHTML(reportId: string, userId: string): Promise<string> {
// Get report data
const { data: report } = await this.supabase
.from('analyzer.analysis_reports')
.select('*')
.eq('id', reportId)
.single()
if (!report) throw new Error('Report not found')
// Generate HTML
const html = this.generateHTML(report.report_data)
// Track export
await this.trackExport(reportId, userId, 'html', html.length)
return html
}
private generateHTML(data: any): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${data.manuscript.title} - Analysis Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
}
.metadata {
color: #666;
font-size: 14px;
}
.score-box {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.big-score {
font-size: 48px;
font-weight: bold;
color: ${data.overallScore >= 85 ? '#28a745' :
data.overallScore >= 70 ? '#ffc107' : '#dc3545'};
}
.section {
margin: 40px 0;
}
.section h2 {
color: #2c3e50;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 10px;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 8px;
}
.category-scores {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin: 20px 0;
}
.category-item {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
}
@media print {
body { padding: 0; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="header">
<h1>Manuscript Analysis Report</h1>
<h2>${data.manuscript.title}</h2>
<div class="metadata">
${data.manuscript.wordCount.toLocaleString()} words •
${data.manuscript.genre} •
${new Date().toLocaleDateString()}
</div>
</div>
<div class="score-box">
<div class="big-score">${data.overallScore}%</div>
<div>Overall Manuscript Score</div>
<strong>
${data.overallScore >= 85 ? 'Publication Ready' :
data.overallScore >= 70 ? 'Needs Revision' :
'Major Work Needed'}
</strong>
</div>
<div class="section">
<h2>Executive Summary</h2>
<p>Your ${data.manuscript.genre} manuscript demonstrates several strengths
while also presenting opportunities for improvement.</p>
</div>
<div class="section">
<h2>Key Strengths</h2>
<ul>
${data.topStrengths.map((s: string) => `<li>${s}</li>`).join('')}
</ul>
</div>
<div class="section">
<h2>Areas for Improvement</h2>
<ul>
${data.topWeaknesses.map((w: string) => `<li>${w}</li>`).join('')}
</ul>
</div>
<div class="section">
<h2>Category Scores</h2>
<div class="category-scores">
${Object.entries(data.categoryScores).map(([cat, score]) => `
<div class="category-item">
<strong>${cat.replace(/_/g, ' ').toUpperCase()}</strong>
<div>${score}%</div>
</div>
`).join('')}
</div>
</div>
<div class="no-print" style="margin-top: 40px; text-align: center;">
<button onclick="window.print()">Print Report</button>
</div>
</body>
</html>
`
}
}5. Export API Routes
// apps/analyzer-app/src/app/api/reports/[id]/export/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { PDFExporter, HTMLExporter } from '@/packages/export'
import { withAuth } from '@/lib/auth'
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await withAuth(req)
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const format = req.nextUrl.searchParams.get('format') || 'pdf'
try {
if (format === 'pdf') {
const exporter = new PDFExporter()
const pdfBuffer = await exporter.exportToPDF(params.id, session.user.id)
return new NextResponse(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="analysis-report.pdf"`
}
})
} else if (format === 'html') {
const exporter = new HTMLExporter()
const html = await exporter.exportToHTML(params.id, session.user.id)
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html',
'Content-Disposition': `attachment; filename="analysis-report.html"`
}
})
} else {
return NextResponse.json(
{ error: 'Invalid format' },
{ status: 400 }
)
}
} catch (error) {
console.error('Export error:', error)
return NextResponse.json(
{ error: 'Export failed' },
{ status: 500 }
)
}
}MVP Acceptance Criteria
- PDF export with professional formatting
- HTML export with inline styles
- Authentication required for exports
- Export tracking in database
- Proper content headers for download
- Mobile-friendly HTML export
- Print-optimized styles
Post-MVP Enhancements
- Word document export
- Email report delivery
- Custom branding options
- Batch export multiple reports
- Scheduled report generation
- Cloud storage integration
- Report sharing links
- Export templates
Implementation Time
- Development: 1.5 days
- Testing: 0.5 days
- Total: 2 days
Dependencies
- F014-REPORT-TEMPLATES (report data structure)
- Report data must be available
Next Feature
After completion, proceed to F016-REVISION-TRACKING for version comparison.