From cc0f9712d6cf975f3d4fc1a0a18d898b9d4febcb Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 23:15:13 +0000 Subject: [PATCH] Added download option for assignment cards Export PDF Download to hook Prevented some NaN's --- src/dashboards/AssignmentCard.tsx | 12 +++-- src/dashboards/Teacher.tsx | 2 +- src/hooks/usePDFDownload.tsx | 60 +++++++++++++++++++++++ src/pages/api/assignments/[id]/export.tsx | 41 ++++++++++------ src/pages/record.tsx | 59 ++-------------------- src/utils/pdf.ts | 25 +++++----- 6 files changed, 111 insertions(+), 88 deletions(-) create mode 100644 src/hooks/usePDFDownload.tsx diff --git a/src/dashboards/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx index 36fa5a43..52b000c1 100644 --- a/src/dashboards/AssignmentCard.tsx +++ b/src/dashboards/AssignmentCard.tsx @@ -2,19 +2,20 @@ import ProgressBar from "@/components/Low/ProgressBar"; import useUsers from "@/hooks/useUsers"; import {Module} from "@/interfaces"; import {Assignment} from "@/interfaces/results"; -import {Stat} from "@/interfaces/user"; import {calculateBandScore} from "@/utils/score"; import clsx from "clsx"; import moment from "moment"; -import {useState} from "react"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import { usePDFDownload } from "@/hooks/usePDFDownload"; interface Props { onClick?: () => void; + allowDownload?: boolean; } -export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) { +export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) { const {users} = useUsers(); + const renderPdfIcon = usePDFDownload("assignments"); const calculateAverageModuleScore = (module: Module) => { const resultModuleBandScores = results.map((r) => { @@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate, onClick={onClick} className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
-

{name}

+
+

{name}

+ {allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} +
Past Assignments ({assignments.filter(pastFilter).length})
{assignments.filter(pastFilter).map((a) => ( - setSelectedAssignment(a)} key={a.id} /> + setSelectedAssignment(a)} key={a.id} allowDownload/> ))}
diff --git a/src/hooks/usePDFDownload.tsx b/src/hooks/usePDFDownload.tsx new file mode 100644 index 00000000..54b83884 --- /dev/null +++ b/src/hooks/usePDFDownload.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { BsFilePdf } from "react-icons/bs"; + +type DownloadingPdf = { + [key: string]: boolean; +}; + +type PdfEndpoint = "stats" | "assignments"; + +export const usePDFDownload = (endpoint: PdfEndpoint) => { + const [downloadingPdf, setDownloadingPdf] = React.useState( + {} + ); + + const triggerDownload = async (id: string) => { + try { + setDownloadingPdf((prev) => ({ ...prev, [id]: true })); + const res = await axios.post(`/api/${endpoint}/${id}/export`); + toast.success("Report ready!"); + const link = document.createElement("a"); + link.href = res.data; + // download should have worked but there are some CORS issues + // https://firebase.google.com/docs/storage/web/download-files#cors_configuration + // link.download="report.pdf"; + link.target = "_blank"; + link.rel = "noreferrer"; + link.click(); + setDownloadingPdf((prev) => ({ ...prev, [id]: false })); + } catch (err) { + toast.error("Failed to display the report!"); + console.error(err); + setDownloadingPdf((prev) => ({ ...prev, [id]: false })); + } + }; + + const renderIcon = ( + id: string, + downloadClasses: string, + loadingClasses: string + ) => { + if (downloadingPdf[id]) { + return ( + + ); + } + return ( + { + e.stopPropagation(); + triggerDownload(id); + }} + /> + ); + }; + + return renderIcon; +}; diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index 380cd846..e3bb9e13 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -15,17 +15,14 @@ import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import ReactPDF from "@react-pdf/renderer"; import GroupTestReport from "@/exams/pdf/group.test.report"; -import { ref, uploadBytes } from "firebase/storage"; +import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; import { ModuleScore, StudentData } from "@/interfaces/module.scores"; -import qrcode from "qrcode"; import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; import { calculateBandScore, getLevelScore } from "@/utils/score"; -import axios from "axios"; -import { moduleLabels } from "@/utils/moduleUtils"; import { generateQRCode, getRadialProgressPNG, @@ -121,17 +118,25 @@ async function post(req: NextApiRequest, res: NextApiResponse) { results: any; exams: { module: Module }[]; startDate: string; + pdf?: string; }; if (!data) { res.status(400).end(); return; } - // TODO: Reenable this - // if (data.assigner !== req.session.user.id) { - // res.status(401).json({ ok: false }); - // return; - // } + if (data.assigner !== req.session.user.id) { + res.status(401).json({ ok: false }); + return; + } + if (data.pdf) { + // if it does, return the pdf url + const fileRef = ref(storage, data.pdf); + const url = await getDownloadURL(fileRef); + + res.status(200).end(url); + return; + } try { const docUser = await getDoc(doc(db, "users", req.session.user.id)); @@ -185,9 +190,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { (e) => e.module === module ); - const bandScore = + const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length; + const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore; const { correct, total } = getScoreAndTotal(moduleResults); const png = getRadialProgressPNG("azul", correct, total); @@ -203,7 +209,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const { correct: overallCorrect, total: overallTotal } = getScoreAndTotal(flattenResults); - const overallResult = overallCorrect / overallTotal; + const baseOverallResult = overallCorrect / overallTotal; + const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult; const overallPNG = getRadialProgressPNG( "laranja", @@ -365,7 +372,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { // generate the file ref for storage const fileName = `${Date.now().toString()}.pdf`; - const fileRef = ref(storage, `assignment_report/${fileName}`); + const refName = `assignment_report/${fileName}`; + const fileRef = ref(storage, refName); // upload the pdf to storage const pdfBuffer = await streamToBuffer(pdfStream); @@ -375,9 +383,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { // update the stats entries with the pdf url to prevent duplication await updateDoc(docSnap.ref, { - pdf: snapshot.ref.fullPath, + pdf: refName, }); - res.status(200).end(snapshot.ref.fullPath); + const url = await getDownloadURL(fileRef); + res.status(200).end(url); return; } @@ -407,7 +416,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } if (data.pdf) { - return res.end(data.pdf); + const fileRef = ref(storage, data.pdf); + const url = await getDownloadURL(fileRef); + return res.redirect(url); } res.status(404).end(); diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 80323849..728cbbc5 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -24,9 +24,7 @@ import useGroups from "@/hooks/useGroups"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import useAssignments from "@/hooks/useAssignments"; import {uuidv4} from "@firebase/util"; -import { BsFilePdf } from "react-icons/bs"; -import axios from "axios"; -import {toast} from "react-toastify"; +import { usePDFDownload } from "@/hooks/usePDFDownload"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -58,10 +56,6 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { }; }, sessionOptions); -type DownloadingPdf = { - [key: string]: boolean; -}; - export default function History({user}: {user: User}) { const [statsUserId, setStatsUserId] = useState(user.id); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); @@ -76,8 +70,8 @@ export default function History({user}: {user: User}) { const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setSelectedModules = useExamStore((state) => state.setSelectedModules); - const [downloadingPdf, setDownloadingPdf] = useState({}); const router = useRouter(); + const renderPdfIcon = usePDFDownload("stats"); useEffect(() => { if (stats && !isStatsLoading) { @@ -208,28 +202,6 @@ export default function History({user}: {user: User}) { correct / total < 0.3 && "text-mti-rose", ); - const triggerDownload = async () => { - try { - setDownloadingPdf((prev) => ({...prev, [session]: true})); - const res = await axios.post(`/api/stats/${session}/export`); - toast.success("Report ready!"); - const link = document.createElement("a"); - link.href = res.data; - // download should have worked but there are some CORS issues - // https://firebase.google.com/docs/storage/web/download-files#cors_configuration - // link.download="report.pdf"; - link.target = '_blank'; - link.rel="noreferrer" - link.click(); - setDownloadingPdf((prev) => ({...prev, [session]: false})); - } catch(err) { - toast.error("Failed to display the report!"); - console.error(err); - setDownloadingPdf((prev) => ({...prev, [session]: false})); - - } - - } const content = ( <>
@@ -248,32 +220,7 @@ export default function History({user}: {user: User}) { Level{" "} {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} - {/* - { - e.stopPropagation(); - triggerDownload(); - }} - /> - */} - {downloadingPdf[session] ? - : - ( - { - e.stopPropagation(); - triggerDownload(); - }} - /> - ) - } + {renderPdfIcon(session, textColor, textColor)}
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 16c0f471..596737b5 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -22,22 +22,23 @@ export const getRadialProgressPNG = ( // calculate the percentage of the score // and round it to the closest available image const percent = (score / total) * 100; + if (isNaN(percent)) return `public/radial_progress/${color}_0.png`; const remainder = percent % 10; const roundedPercent = percent - remainder; return `public/radial_progress/${color}_${roundedPercent}.png`; }; export const streamToBuffer = async ( - stream: NodeJS.ReadableStream - ): Promise => { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (data) => { - chunks.push(data); - }); - stream.on("end", () => { - resolve(Buffer.concat(chunks)); - }); - stream.on("error", reject); + stream: NodeJS.ReadableStream +): Promise => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (data) => { + chunks.push(data); }); - }; \ No newline at end of file + stream.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + stream.on("error", reject); + }); +};