From 6b18c208c999ee4babfc7a2eb6a7e5acfaecebd7 Mon Sep 17 00:00:00 2001 From: aryanm9026 Date: Mon, 6 Jul 2026 00:32:24 +0530 Subject: [PATCH] feat: add image support to classroom PDF export --- src/app/(routes)/dashboard/analytics/page.tsx | 456 +++++++++--------- src/components/common/ExportButton.tsx | 7 +- src/lib/pdf/exportPDF.ts | 71 ++- 3 files changed, 300 insertions(+), 234 deletions(-) diff --git a/src/app/(routes)/dashboard/analytics/page.tsx b/src/app/(routes)/dashboard/analytics/page.tsx index 934dd574..27270d56 100644 --- a/src/app/(routes)/dashboard/analytics/page.tsx +++ b/src/app/(routes)/dashboard/analytics/page.tsx @@ -4,13 +4,13 @@ import { useEffect, useState } from "react"; import { useAppUser } from "@/app/provider"; import { LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, AreaChart, Area, - XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; -import { - BarChart3, LineChart as LineIcon, PieChart as PieIcon, - TrendingUp, Clock, Users, Download, Calendar, - Sparkles, Filter, ArrowLeft, AlertCircle, CheckCircle2, - ShieldAlert, Loader2, BookOpen +import { + BarChart3, LineChart as LineIcon, PieChart as PieIcon, + TrendingUp, Clock, Users, Download, Calendar, + Sparkles, Filter, ArrowLeft, AlertCircle, CheckCircle2, + ShieldAlert, Loader2, BookOpen } from "lucide-react"; import { useRouter } from "next/navigation"; import { Skeleton } from "@/components/ui/skeleton"; @@ -83,7 +83,7 @@ export default function AnalyticsDashboard() {

Authentication Required

Please sign in to your instructor account to view learning metrics.

- - +

Teacher Analytics @@ -197,7 +197,7 @@ export default function AnalyticsDashboard() { )} @@ -205,7 +205,7 @@ export default function AnalyticsDashboard() {
- +
) : ( data && ( - <> -
- {[ - { - label: "Total Doubts asked", - value: data.summary.totalDoubts, - detail: `In last ${dateRange} days`, - icon: BookOpen, - color: "text-purple-600 dark:text-purple-400", - bg: "bg-purple-500/10", - border: "border-slate-200 dark:border-zinc-900" - }, - { - label: "Avg Resolution Rate", - value: `${data.summary.resolutionRate}%`, - detail: `${data.summary.solvedDoubts} Solved out of ${data.summary.totalDoubts}`, - icon: CheckCircle2, - color: "text-emerald-600 dark:text-emerald-400", - bg: "bg-emerald-500/10", - border: "border-slate-200 dark:border-zinc-900" - }, - { - label: "Avg Response Time", - value: data.summary.averageResponseTime === 0 ? "N/A" : `${data.summary.averageResponseTime}m`, - detail: "Time to first reply", - icon: Clock, - color: "text-blue-600 dark:text-blue-400", - bg: "bg-blue-500/10", - border: "border-slate-200 dark:border-zinc-900" - }, - { - label: "Engaged Students", - value: data.summary.activeStudents, - detail: "Students active in timeframe", - icon: Users, - color: "text-pink-600 dark:text-pink-400", - bg: "bg-pink-500/10", - border: "border-slate-200 dark:border-zinc-900" - } - ].map((card, i) => ( -
-
-
- + <> +
+ {[ + { + label: "Total Doubts asked", + value: data.summary.totalDoubts, + detail: `In last ${dateRange} days`, + icon: BookOpen, + color: "text-purple-600 dark:text-purple-400", + bg: "bg-purple-500/10", + border: "border-slate-200 dark:border-zinc-900" + }, + { + label: "Avg Resolution Rate", + value: `${data.summary.resolutionRate}%`, + detail: `${data.summary.solvedDoubts} Solved out of ${data.summary.totalDoubts}`, + icon: CheckCircle2, + color: "text-emerald-600 dark:text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-slate-200 dark:border-zinc-900" + }, + { + label: "Avg Response Time", + value: data.summary.averageResponseTime === 0 ? "N/A" : `${data.summary.averageResponseTime}m`, + detail: "Time to first reply", + icon: Clock, + color: "text-blue-600 dark:text-blue-400", + bg: "bg-blue-500/10", + border: "border-slate-200 dark:border-zinc-900" + }, + { + label: "Engaged Students", + value: data.summary.activeStudents, + detail: "Students active in timeframe", + icon: Users, + color: "text-pink-600 dark:text-pink-400", + bg: "bg-pink-500/10", + border: "border-slate-200 dark:border-zinc-900" + } + ].map((card, i) => ( +
+
+
+ +
+ {card.value} +
+
+

{card.label}

+

{card.detail}

- {card.value} -
-
-

{card.label}

-

{card.detail}

-
- ))} -
- -
-
-
- -
-
-

AI-Driven Teacher Action Plan

-

- {data.summary.resolutionRate < 60 - ? "Critical: Doubts are accumulating faster than resolution rates. Host an immediate doubt-clearing session for highly active topics." - : data.summary.averageResponseTime > 60 - ? "Pace warning: The average reply latency is currently high. Instruct AI Solver integrations or host quick daily recaps." - : "Curriculum Grasp Stable: Classroom resolution rate is exemplary. Continue providing advanced elective challenges." - } -

-
+ ))}
-
-
- -
-
-
-

Doubt Activity trends

-

Daily volume of questions asked

+
+
+
+
-
- +
+

AI-Driven Teacher Action Plan

+

+ {data.summary.resolutionRate < 60 + ? "Critical: Doubts are accumulating faster than resolution rates. Host an immediate doubt-clearing session for highly active topics." + : data.summary.averageResponseTime > 60 + ? "Pace warning: The average reply latency is currently high. Instruct AI Solver integrations or host quick daily recaps." + : "Curriculum Grasp Stable: Classroom resolution rate is exemplary. Continue providing advanced elective challenges." + } +

-
- - - - - - - - - -
-
-
-
-

Most-Asked Subjects

-

Queries distribution by subject

-
-
- -
-
-
- {data.subjects.length === 0 ? ( -
- No Subjects Data Available +
+ +
+
+
+

Doubt Activity trends

+

Daily volume of questions asked

- ) : ( +
+ +
+
+
- - - - + + + + + - - {data.subjects.map((entry: any, index: number) => ( - - ))} - - + - )} +
-
-
-
-
-

Doubt Resolution Status

-

Ratio of Solved vs Unsolved Doubts

+
+
+
+

Most-Asked Subjects

+

Queries distribution by subject

+
+
+ +
-
- +
+ {data.subjects.length === 0 ? ( +
+ No Subjects Data Available +
+ ) : ( + + + + + + + + {data.subjects.map((entry: any, index: number) => ( + + ))} + + + + )}
-
-
- - - - - - - - - + +
+
+
+

Doubt Resolution Status

+

Ratio of Solved vs Unsolved Doubts

+
+
+ +
-
-
-
-
-

Solved Doubts

-

{data.summary.solvedDoubts} queries ({data.summary.resolutionRate}%)

-
+
+
+ + + + + + + + +
-
-
-
-

Unsolved Doubts

-

{data.summary.unsolvedDoubts} queries ({100 - data.summary.resolutionRate}%)

+
+
+
+
+

Solved Doubts

+

{data.summary.solvedDoubts} queries ({data.summary.resolutionRate}%)

+
+
+
+
+
+

Unsolved Doubts

+

{data.summary.unsolvedDoubts} queries ({100 - data.summary.resolutionRate}%)

+
-
-
-
-
-

Peak Doubt Hours

-

Doubt volume by time of day

+
+
+
+

Peak Doubt Hours

+

Doubt volume by time of day

+
+
+ +
-
- +
+ + + + + + + + + + + + + + +
-
- - - - - - - - - - - - - - - -
-
-
- - ))} +
+ + ))}
diff --git a/src/components/common/ExportButton.tsx b/src/components/common/ExportButton.tsx index ab85f339..bfadebd5 100644 --- a/src/components/common/ExportButton.tsx +++ b/src/components/common/ExportButton.tsx @@ -33,7 +33,10 @@ export default function ExportButton({ classroomId, classroomName, isTeacher }: } const data: ExportAPIResponse = await res.json(); - exportDoubtsPDF(data.classroomName, data.doubts); + + // Added the 'await' keyword here! + await exportDoubtsPDF(data.classroomName, data.doubts); + toast.success("PDF exported successfully!"); } catch (error: unknown) { console.error("Export error:", error); @@ -63,4 +66,4 @@ export default function ExportButton({ classroomId, classroomName, isTeacher }: )} ); -} +} \ No newline at end of file diff --git a/src/lib/pdf/exportPDF.ts b/src/lib/pdf/exportPDF.ts index 996026a0..8800d437 100644 --- a/src/lib/pdf/exportPDF.ts +++ b/src/lib/pdf/exportPDF.ts @@ -1,5 +1,18 @@ import { jsPDF } from "jspdf"; +/** + * Helper function to load an image asynchronously before adding it to jsPDF. + */ +const loadImage = (url: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "Anonymous"; + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = url; + }); +}; + /** * Doubt type matching the doubtsTable schema from configs/schema.ts * with the additional replyCount field appended by the API. @@ -23,11 +36,12 @@ export interface ExportDoubt { /** * Generates and downloads a PDF report of classroom doubts. + * Now asynchronous to handle image loading. * * @param classroomName - Name of the classroom * @param doubts - Array of doubts to include in the PDF */ -export function exportDoubtsPDF(classroomName: string, doubts: ExportDoubt[]): void { +export async function exportDoubtsPDF(classroomName: string, doubts: ExportDoubt[]): Promise { const doc = new jsPDF("p", "mm", "a4"); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); @@ -93,7 +107,10 @@ export function exportDoubtsPDF(classroomName: string, doubts: ExportDoubt[]): v doc.text("No doubts found matching the selected filters.", marginLeft, y); } - doubts.forEach((doubt, index) => { + // Using a for...of loop instead of .forEach() to allow awaiting image loads + for (let index = 0; index < doubts.length; index++) { + const doubt = doubts[index]; + // Estimate space needed for this doubt block (minimum ~35mm) checkPageOverflow(45); @@ -128,6 +145,52 @@ export function exportDoubtsPDF(classroomName: string, doubts: ExportDoubt[]): v y += 2; } + // ── Image Handling ───────────────────────────────────── + // Ensure we don't try to render embedded PDF documents as images + if (doubt.imageUrl && !doubt.imageUrl.startsWith("data:application/pdf")) { + try { + const img = await loadImage(doubt.imageUrl); + + // Set constraints so the image doesn't blow out the PDF page + const maxImgWidth = contentWidth; + const maxImgHeight = 80; // Bound to 80mm maximum height + + let renderWidth = img.width; + let renderHeight = img.height; + const ratio = renderWidth / renderHeight; + + // Scale down based on width + if (renderWidth > maxImgWidth) { + renderWidth = maxImgWidth; + renderHeight = renderWidth / ratio; + } + + // Scale down based on height + if (renderHeight > maxImgHeight) { + renderHeight = maxImgHeight; + renderWidth = renderHeight * ratio; + } + + // Check if the image will push us off the bottom of the page + checkPageOverflow(renderHeight + 10); + + // Try extracting format from URL, default to PNG to be safe + const fileExtMatch = doubt.imageUrl.match(/\.(jpeg|jpg|gif|png|webp)/i); + let format = fileExtMatch ? fileExtMatch[1].toUpperCase() : 'PNG'; + if (format === 'JPG') format = 'JPEG'; + + doc.addImage(img, format, marginLeft, y, renderWidth, renderHeight); + y += renderHeight + 5; + } catch (error) { + console.warn(`Failed to embed image for doubt #${index + 1}`, error); + checkPageOverflow(6); + doc.setFontSize(9); + doc.setTextColor(239, 68, 68); // Red-500 + doc.text("[Image attachment failed to load]", marginLeft, y); + y += 6; + } + } + // Metadata row doc.setFontSize(9); doc.setFont("helvetica", "normal"); @@ -172,7 +235,7 @@ export function exportDoubtsPDF(classroomName: string, doubts: ExportDoubt[]): v doc.line(marginLeft, y, pageWidth - marginRight, y); y += 8; } - }); + } // ── Footer on last page ───────────────────────────────── doc.setFontSize(8); @@ -188,4 +251,4 @@ export function exportDoubtsPDF(classroomName: string, doubts: ExportDoubt[]): v const sanitizedName = classroomName.replace(/[^a-zA-Z0-9]/g, "_"); const dateStamp = new Date().toISOString().split("T")[0]; doc.save(`DoubtDesk_${sanitizedName}_${dateStamp}.pdf`); -} +} \ No newline at end of file