From bed2f98d9cdf65141f88d6d303178fac4b802e27 Mon Sep 17 00:00:00 2001 From: Adrien Reibel Date: Wed, 18 Mar 2026 17:59:04 +0100 Subject: [PATCH 1/2] 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. --- frontend/app/globals.css | 10 + frontend/app/project/[id]/dashboard/page.tsx | 50 ++++- .../components/dashboard/dashboard-grid.tsx | 15 +- frontend/lib/export-dashboard-pdf.ts | 41 ++++ frontend/package-lock.json | 179 +++++++++++++++++- frontend/package.json | 2 + 6 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 frontend/lib/export-dashboard-pdf.ts diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 5b70bc5..6be414a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -267,6 +267,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; diff --git a/frontend/app/project/[id]/dashboard/page.tsx b/frontend/app/project/[id]/dashboard/page.tsx index 153525c..2bc5b13 100644 --- a/frontend/app/project/[id]/dashboard/page.tsx +++ b/frontend/app/project/[id]/dashboard/page.tsx @@ -1,14 +1,16 @@ 'use client' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef, 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 type { DashboardCard, CardLayout } from '@/lib/domain-types' +import type { DashboardGridHandle } from '@/components/dashboard/dashboard-grid' const DashboardGrid = dynamic( () => import('@/components/dashboard/dashboard-grid'), @@ -32,6 +34,20 @@ export default function ProjectDashboardPage() { const isGeneratingDashboard = useDashboardStore((s) => s.isGeneratingDashboard) const generateDashboard = useDashboardStore((s) => s.generateDashboard) + const gridRef = useRef(null) + const [isExporting, setIsExporting] = useState(false) + + const handleExport = useCallback(async () => { + const el = gridRef.current?.container + if (!el) return + setIsExporting(true) + try { + await exportDashboardPdf(el) + } finally { + setIsExporting(false) + } + }, []) + useEffect(() => { if (projectId) loadCards(projectId) }, [projectId, loadCards]) @@ -112,13 +128,29 @@ export default function ProjectDashboardPage() { ) : ( - + <> +
+ +
+ + )} ) diff --git a/frontend/components/dashboard/dashboard-grid.tsx b/frontend/components/dashboard/dashboard-grid.tsx index d462d67..6f15f3c 100644 --- a/frontend/components/dashboard/dashboard-grid.tsx +++ b/frontend/components/dashboard/dashboard-grid.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' import { ReactGridLayout } from 'react-grid-layout/legacy' import { CheckIcon, GripVerticalIcon, Trash2Icon } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -66,18 +66,23 @@ interface DashboardGridProps { onCardContentChange: (cardId: string, content: unknown[]) => void } -export default function DashboardGrid({ +export interface DashboardGridHandle { + readonly container: HTMLDivElement | null +} + +const DashboardGrid = forwardRef(function DashboardGrid({ cards, onRemoveCard, onAddNote, onLayoutChange, onCardContentChange, -}: DashboardGridProps) { +}, ref) { const selectedCardIds = useDashboardStore((s) => s.selectedCardIds) const toggleCardSelection = useDashboardStore((s) => s.toggleCardSelection) const clearSelection = useDashboardStore((s) => s.clearSelection) const containerRef = useRef(null) + useImperativeHandle(ref, () => ({ get container() { return containerRef.current } }), []) const [width, setWidth] = useState(1200) // Track cards and callback in refs so ResizeObserver/event handlers @@ -222,4 +227,6 @@ export default function DashboardGrid({ ) -} +}) + +export default DashboardGrid diff --git a/frontend/lib/export-dashboard-pdf.ts b/frontend/lib/export-dashboard-pdf.ts new file mode 100644 index 0000000..ba2f6f3 --- /dev/null +++ b/frontend/lib/export-dashboard-pdf.ts @@ -0,0 +1,41 @@ +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 { + // Hide interactive chrome during capture + gridElement.classList.add('exporting') + + try { + const canvas = await html2canvas(gridElement, { + scale: 2, + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + }) + + 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 { + gridElement.classList.remove('exporting') + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f25384..1004709 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,8 @@ "@types/react-grid-layout": "^1.3.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html2canvas-pro": "^2.0.2", + "jspdf": "^4.2.1", "lucide-react": "^0.575.0", "motion": "^12.34.3", "next": "^16.1.6", @@ -268,6 +270,14 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -3948,6 +3958,11 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -3975,6 +3990,12 @@ "version": "15.7.15", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.28", "license": "MIT", @@ -4956,7 +4977,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "peer": true, "engines": { "node": ">= 0.6.0" } @@ -5163,6 +5183,25 @@ "element-size": "^1.1.1" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -5464,6 +5503,17 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5550,6 +5600,14 @@ "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", "peer": true }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-system-font-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", @@ -7302,6 +7360,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "license": "ISC", @@ -7309,6 +7377,11 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8513,6 +8586,31 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2canvas-pro": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz", + "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8620,6 +8718,11 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -9223,6 +9326,22 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -11446,6 +11565,11 @@ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11565,8 +11689,7 @@ "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "peer": true + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/pick-by-alias": { "version": "1.2.0", @@ -12313,7 +12436,6 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "peer": true, "dependencies": { "performance-now": "^2.1.0" } @@ -12565,6 +12687,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "optional": true + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -13013,6 +13141,15 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/right-now": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", @@ -13423,6 +13560,15 @@ "node": "*" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/static-eval": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", @@ -13832,6 +13978,15 @@ "svg-path-bounds": "^1.0.1" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -13874,6 +14029,14 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -14538,6 +14701,14 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f9fd6b0..f4a3e23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,8 @@ "@types/react-grid-layout": "^1.3.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html2canvas-pro": "^2.0.2", + "jspdf": "^4.2.1", "lucide-react": "^0.575.0", "motion": "^12.34.3", "next": "^16.1.6", From 4ca0d3c864222da97004137d4e83968bf3901d81 Mon Sep 17 00:00:00 2001 From: Adrien Reibel Date: Wed, 18 Mar 2026 18:09:30 +0100 Subject: [PATCH 2/2] 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 --- frontend/app/project/[id]/dashboard/page.tsx | 8 +++----- frontend/components/dashboard/dashboard-grid.tsx | 14 ++++++-------- frontend/lib/export-dashboard-pdf.ts | 13 ++++++++++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/app/project/[id]/dashboard/page.tsx b/frontend/app/project/[id]/dashboard/page.tsx index 2bc5b13..64bdec1 100644 --- a/frontend/app/project/[id]/dashboard/page.tsx +++ b/frontend/app/project/[id]/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams } from 'next/navigation' import { DownloadIcon, LayoutGridIcon, Loader2 } from 'lucide-react' import Image from 'next/image' @@ -9,8 +9,8 @@ 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' -import type { DashboardGridHandle } from '@/components/dashboard/dashboard-grid' const DashboardGrid = dynamic( () => import('@/components/dashboard/dashboard-grid'), @@ -34,11 +34,10 @@ export default function ProjectDashboardPage() { const isGeneratingDashboard = useDashboardStore((s) => s.isGeneratingDashboard) const generateDashboard = useDashboardStore((s) => s.generateDashboard) - const gridRef = useRef(null) const [isExporting, setIsExporting] = useState(false) const handleExport = useCallback(async () => { - const el = gridRef.current?.container + const el = document.getElementById(DASHBOARD_GRID_ID) if (!el) return setIsExporting(true) try { @@ -143,7 +142,6 @@ export default function ProjectDashboardPage() { void } -export interface DashboardGridHandle { - readonly container: HTMLDivElement | null -} +export const DASHBOARD_GRID_ID = 'dashboard-grid' -const DashboardGrid = forwardRef(function DashboardGrid({ +function DashboardGrid({ cards, onRemoveCard, onAddNote, onLayoutChange, onCardContentChange, -}, ref) { +}: DashboardGridProps) { const selectedCardIds = useDashboardStore((s) => s.selectedCardIds) const toggleCardSelection = useDashboardStore((s) => s.toggleCardSelection) const clearSelection = useDashboardStore((s) => s.clearSelection) const containerRef = useRef(null) - useImperativeHandle(ref, () => ({ get container() { return containerRef.current } }), []) const [width, setWidth] = useState(1200) // Track cards and callback in refs so ResizeObserver/event handlers @@ -156,6 +153,7 @@ const DashboardGrid = forwardRef(functi return (
{ @@ -227,6 +225,6 @@ const DashboardGrid = forwardRef(functi
) -}) +} export default DashboardGrid diff --git a/frontend/lib/export-dashboard-pdf.ts b/frontend/lib/export-dashboard-pdf.ts index ba2f6f3..e9a2840 100644 --- a/frontend/lib/export-dashboard-pdf.ts +++ b/frontend/lib/export-dashboard-pdf.ts @@ -8,8 +8,15 @@ export async function exportDashboardPdf( gridElement: HTMLElement, filename = 'dashboard.pdf', ): Promise { - // Hide interactive chrome during capture + // Hide interactive chrome and expand overflow so the full grid is captured gridElement.classList.add('exporting') + const scrollParent = gridElement.closest('.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, { @@ -36,6 +43,10 @@ export async function exportDashboardPdf( pdf.save(filename) } finally { + if (scrollParent) { + scrollParent.style.overflow = savedOverflow ?? '' + scrollParent.style.height = savedHeight ?? '' + } gridElement.classList.remove('exporting') } }