Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All @@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

useEffect(() => {
setIsMounted(true)
}, [])

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All @@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
let chatTitle = 'Untitled Chat'
if (aiState.messages.length > 0) {
const firstMessage = aiState.messages[0]
const content = typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

try {
const parsed = JSON.parse(content)
chatTitle = parsed.input || content
} catch (e) {
chatTitle = content
const parsed = JSON.parse(rawContent)
chatTitle = parsed.input || rawContent
} catch {
chatTitle = rawContent
}
}
const finalTitle = (chatTitle || 'QCX Analysis Report').substring(0, 50)
setReportTitle(finalTitle)

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All @@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand Down Expand Up @@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All @@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All @@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All @@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{drawnFeatures.map((feature) => (
<tr key={feature.id}>
<td className="px-4 py-2 whitespace-nowrap text-gray-900">{feature.type}</td>
<td className="px-4 py-2 whitespace-nowrap text-gray-900">{feature.measurement}</td>
<td className="px-4 py-2 text-gray-500 break-all font-mono text-[10px]">

{JSON.stringify(feature.geometry.coordinates).substring(0, 100)}...
</td>
</tr>
Expand Down