Skip to content

Commit 317ad94

Browse files
OrssoAdrien Reibel
andauthored
feat: add PDF export for dashboards (#4)
* feat: add PDF export for dashboards Client-side capture using html2canvas-pro + jsPDF. Adds an "Export PDF" button in the dashboard toolbar that renders the grid to a multi-page landscape PDF. Interactive chrome (drag handles, resize handles, FAB) is hidden during capture via a CSS class. * fix: use DOM id instead of ref for PDF export, capture full grid height - Replace forwardRef/useImperativeHandle with a stable DOM id, fixing the ref not forwarding through next/dynamic - Temporarily set scroll parent to overflow:visible + height:auto before capture so dashboards taller than the viewport are fully rasterized --------- Co-authored-by: Adrien Reibel <areibel@sqli.com>
1 parent de269f4 commit 317ad94

6 files changed

Lines changed: 311 additions & 1153 deletions

File tree

frontend/app/globals.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,16 @@
303303
outline: none !important;
304304
}
305305

306+
/* PDF export: hide interactive chrome during capture */
307+
.exporting .drag-handle,
308+
.exporting .react-resizable-handle,
309+
.exporting [aria-label="Add note"] {
310+
display: none !important;
311+
}
312+
.exporting .react-grid-item {
313+
box-shadow: none !important;
314+
}
315+
306316
/* custom scrollbar */
307317
::-webkit-scrollbar {
308318
width: 6px;

frontend/app/project/[id]/dashboard/page.tsx

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
'use client'
22

3-
import { useCallback, useEffect } from 'react'
3+
import { useCallback, useEffect, useState } from 'react'
44
import { useParams } from 'next/navigation'
5-
import { LayoutGridIcon, Loader2 } from 'lucide-react'
5+
import { DownloadIcon, LayoutGridIcon, Loader2 } from 'lucide-react'
66
import Image from 'next/image'
77
import dynamic from 'next/dynamic'
88

99
import { Button } from '@/components/ui/button'
1010
import { useDashboardStore } from '@/lib/stores/dashboard-store'
11+
import { exportDashboardPdf } from '@/lib/export-dashboard-pdf'
12+
import { DASHBOARD_GRID_ID } from '@/components/dashboard/dashboard-grid'
1113
import type { DashboardCard, CardLayout } from '@/lib/domain-types'
1214

1315
const DashboardGrid = dynamic(
@@ -32,6 +34,19 @@ export default function ProjectDashboardPage() {
3234
const isGeneratingDashboard = useDashboardStore((s) => s.isGeneratingDashboard)
3335
const generateDashboard = useDashboardStore((s) => s.generateDashboard)
3436

37+
const [isExporting, setIsExporting] = useState(false)
38+
39+
const handleExport = useCallback(async () => {
40+
const el = document.getElementById(DASHBOARD_GRID_ID)
41+
if (!el) return
42+
setIsExporting(true)
43+
try {
44+
await exportDashboardPdf(el)
45+
} finally {
46+
setIsExporting(false)
47+
}
48+
}, [])
49+
3550
useEffect(() => {
3651
if (projectId) loadCards(projectId)
3752
}, [projectId, loadCards])
@@ -112,13 +127,28 @@ export default function ProjectDashboardPage() {
112127
<Loader2 className="animate-spin size-6 text-primary" />
113128
</div>
114129
) : (
115-
<DashboardGrid
116-
cards={dashboardCards}
117-
onRemoveCard={handleRemoveCard}
118-
onAddNote={handleAddNote}
119-
onLayoutChange={handleLayoutChange}
120-
onCardContentChange={handleCardContentChange}
121-
/>
130+
<>
131+
<div className="mb-2 flex items-center justify-end">
132+
<button
133+
type="button"
134+
onClick={handleExport}
135+
disabled={isExporting}
136+
className="flex items-center gap-1.5 rounded-md border border-border/60 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
137+
>
138+
{isExporting
139+
? <Loader2 className="size-3.5 animate-spin" />
140+
: <DownloadIcon className="size-3.5" />}
141+
Export PDF
142+
</button>
143+
</div>
144+
<DashboardGrid
145+
cards={dashboardCards}
146+
onRemoveCard={handleRemoveCard}
147+
onAddNote={handleAddNote}
148+
onLayoutChange={handleLayoutChange}
149+
onCardContentChange={handleCardContentChange}
150+
/>
151+
</>
122152
)}
123153
</div>
124154
)

frontend/components/dashboard/dashboard-grid.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ interface DashboardGridProps {
6666
onCardContentChange: (cardId: string, content: unknown[]) => void
6767
}
6868

69-
export default function DashboardGrid({
69+
export const DASHBOARD_GRID_ID = 'dashboard-grid'
70+
71+
function DashboardGrid({
7072
cards,
7173
onRemoveCard,
7274
onAddNote,
@@ -151,6 +153,7 @@ export default function DashboardGrid({
151153

152154
return (
153155
<div
156+
id={DASHBOARD_GRID_ID}
154157
ref={containerRef}
155158
className="relative h-full w-full"
156159
onDoubleClick={(e) => {
@@ -223,3 +226,5 @@ export default function DashboardGrid({
223226
</div>
224227
)
225228
}
229+
230+
export default DashboardGrid
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import html2canvas from 'html2canvas-pro'
2+
import { jsPDF } from 'jspdf'
3+
4+
/**
5+
* Capture the dashboard grid element and save it as a multi-page landscape PDF.
6+
*/
7+
export async function exportDashboardPdf(
8+
gridElement: HTMLElement,
9+
filename = 'dashboard.pdf',
10+
): Promise<void> {
11+
// Hide interactive chrome and expand overflow so the full grid is captured
12+
gridElement.classList.add('exporting')
13+
const scrollParent = gridElement.closest<HTMLElement>('.overflow-auto')
14+
const savedOverflow = scrollParent?.style.overflow
15+
const savedHeight = scrollParent?.style.height
16+
if (scrollParent) {
17+
scrollParent.style.overflow = 'visible'
18+
scrollParent.style.height = 'auto'
19+
}
20+
21+
try {
22+
const canvas = await html2canvas(gridElement, {
23+
scale: 2,
24+
useCORS: true,
25+
backgroundColor: '#ffffff',
26+
logging: false,
27+
})
28+
29+
const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: 'letter' })
30+
const pageW = pdf.internal.pageSize.getWidth()
31+
const pageH = pdf.internal.pageSize.getHeight()
32+
33+
const imgW = pageW
34+
const imgH = (canvas.height * pageW) / canvas.width
35+
const imgData = canvas.toDataURL('image/png')
36+
37+
let y = 0
38+
while (y < imgH) {
39+
if (y > 0) pdf.addPage()
40+
pdf.addImage(imgData, 'PNG', 0, -y, imgW, imgH)
41+
y += pageH
42+
}
43+
44+
pdf.save(filename)
45+
} finally {
46+
if (scrollParent) {
47+
scrollParent.style.overflow = savedOverflow ?? ''
48+
scrollParent.style.height = savedHeight ?? ''
49+
}
50+
gridElement.classList.remove('exporting')
51+
}
52+
}

0 commit comments

Comments
 (0)