Skip to content
Merged
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
10 changes: 10 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,16 @@
outline: none !important;
}

/* PDF export: hide interactive chrome during capture */
.exporting .drag-handle,
.exporting .react-resizable-handle,
.exporting [aria-label="Add note"] {
display: none !important;
}
.exporting .react-grid-item {
box-shadow: none !important;
}

/* custom scrollbar */
::-webkit-scrollbar {
width: 6px;
Expand Down
48 changes: 39 additions & 9 deletions frontend/app/project/[id]/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
'use client'

import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { LayoutGridIcon, Loader2 } from 'lucide-react'
import { DownloadIcon, LayoutGridIcon, Loader2 } from 'lucide-react'
import Image from 'next/image'
import dynamic from 'next/dynamic'

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

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

const [isExporting, setIsExporting] = useState(false)

const handleExport = useCallback(async () => {
const el = document.getElementById(DASHBOARD_GRID_ID)
if (!el) return
setIsExporting(true)
try {
await exportDashboardPdf(el)
} finally {
setIsExporting(false)
}
}, [])

useEffect(() => {
if (projectId) loadCards(projectId)
}, [projectId, loadCards])
Expand Down Expand Up @@ -112,13 +127,28 @@ export default function ProjectDashboardPage() {
<Loader2 className="animate-spin size-6 text-primary" />
</div>
) : (
<DashboardGrid
cards={dashboardCards}
onRemoveCard={handleRemoveCard}
onAddNote={handleAddNote}
onLayoutChange={handleLayoutChange}
onCardContentChange={handleCardContentChange}
/>
<>
<div className="mb-2 flex items-center justify-end">
<button
type="button"
onClick={handleExport}
disabled={isExporting}
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"
>
{isExporting
? <Loader2 className="size-3.5 animate-spin" />
: <DownloadIcon className="size-3.5" />}
Export PDF
</button>
</div>
<DashboardGrid
cards={dashboardCards}
onRemoveCard={handleRemoveCard}
onAddNote={handleAddNote}
onLayoutChange={handleLayoutChange}
onCardContentChange={handleCardContentChange}
/>
</>
)}
</div>
)
Expand Down
7 changes: 6 additions & 1 deletion frontend/components/dashboard/dashboard-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ interface DashboardGridProps {
onCardContentChange: (cardId: string, content: unknown[]) => void
}

export default function DashboardGrid({
export const DASHBOARD_GRID_ID = 'dashboard-grid'

function DashboardGrid({
cards,
onRemoveCard,
onAddNote,
Expand Down Expand Up @@ -151,6 +153,7 @@ export default function DashboardGrid({

return (
<div
id={DASHBOARD_GRID_ID}
ref={containerRef}
className="relative h-full w-full"
onDoubleClick={(e) => {
Expand Down Expand Up @@ -223,3 +226,5 @@ export default function DashboardGrid({
</div>
)
}

export default DashboardGrid
52 changes: 52 additions & 0 deletions frontend/lib/export-dashboard-pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import html2canvas from 'html2canvas-pro'
import { jsPDF } from 'jspdf'

/**
* Capture the dashboard grid element and save it as a multi-page landscape PDF.
*/
export async function exportDashboardPdf(
gridElement: HTMLElement,
filename = 'dashboard.pdf',
): Promise<void> {
// Hide interactive chrome and expand overflow so the full grid is captured
gridElement.classList.add('exporting')
const scrollParent = gridElement.closest<HTMLElement>('.overflow-auto')
const savedOverflow = scrollParent?.style.overflow
const savedHeight = scrollParent?.style.height
if (scrollParent) {
scrollParent.style.overflow = 'visible'
scrollParent.style.height = 'auto'
}

try {
const canvas = await html2canvas(gridElement, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
})
Comment on lines +22 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Capture the full dashboard height before rasterizing

exportDashboardPdf() snapshots gridElement directly with html2canvas, but the referenced wrapper is the h-full container from dashboard-grid.tsx, not a full-height render target. For dashboards taller than the viewport, the canvas only contains the visible first screen, so the pagination loop just slices that partial capture and the lower cards never appear in the exported PDF.

Useful? React with 👍 / 👎.


const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: 'letter' })
const pageW = pdf.internal.pageSize.getWidth()
const pageH = pdf.internal.pageSize.getHeight()

const imgW = pageW
const imgH = (canvas.height * pageW) / canvas.width
const imgData = canvas.toDataURL('image/png')

let y = 0
while (y < imgH) {
if (y > 0) pdf.addPage()
pdf.addImage(imgData, 'PNG', 0, -y, imgW, imgH)
y += pageH
}

pdf.save(filename)
} finally {
if (scrollParent) {
scrollParent.style.overflow = savedOverflow ?? ''
scrollParent.style.height = savedHeight ?? ''
}
gridElement.classList.remove('exporting')
}
}
Loading
Loading