Added download option for assignment cards
Export PDF Download to hook Prevented some NaN's
This commit is contained in:
@@ -2,19 +2,20 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useState} from "react";
|
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
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 {users} = useUsers();
|
||||||
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
@@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
|
|||||||
onClick={onClick}
|
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">
|
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">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="font-semibold text-xl">{name}</h3>
|
<div className="flex flex-row justify-between">
|
||||||
|
<h3 className="font-semibold text-xl">{name}</h3>
|
||||||
|
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
percentage={(results.length / assignees.length) * 100}
|
percentage={(results.length / assignees.length) * 100}
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
{assignments.filter(pastFilter).map((a) => (
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
60
src/hooks/usePDFDownload.tsx
Normal file
60
src/hooks/usePDFDownload.tsx
Normal file
@@ -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<DownloadingPdf>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BsFilePdf
|
||||||
|
className={`${downloadClasses} text-2xl cursor-pointer`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
triggerDownload(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderIcon;
|
||||||
|
};
|
||||||
@@ -15,17 +15,14 @@ import { withIronSessionApiRoute } from "iron-session/next";
|
|||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import ReactPDF from "@react-pdf/renderer";
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
import GroupTestReport from "@/exams/pdf/group.test.report";
|
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 { Stat } from "@/interfaces/user";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { ModuleScore, StudentData } from "@/interfaces/module.scores";
|
import { ModuleScore, StudentData } from "@/interfaces/module.scores";
|
||||||
import qrcode from "qrcode";
|
|
||||||
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||||
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
||||||
import { calculateBandScore, getLevelScore } from "@/utils/score";
|
import { calculateBandScore, getLevelScore } from "@/utils/score";
|
||||||
import axios from "axios";
|
|
||||||
import { moduleLabels } from "@/utils/moduleUtils";
|
|
||||||
import {
|
import {
|
||||||
generateQRCode,
|
generateQRCode,
|
||||||
getRadialProgressPNG,
|
getRadialProgressPNG,
|
||||||
@@ -121,17 +118,25 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
results: any;
|
results: any;
|
||||||
exams: { module: Module }[];
|
exams: { module: Module }[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
|
pdf?: string;
|
||||||
};
|
};
|
||||||
if (!data) {
|
if (!data) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Reenable this
|
if (data.assigner !== req.session.user.id) {
|
||||||
// if (data.assigner !== req.session.user.id) {
|
res.status(401).json({ ok: false });
|
||||||
// res.status(401).json({ ok: false });
|
return;
|
||||||
// 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 {
|
try {
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
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
|
(e) => e.module === module
|
||||||
);
|
);
|
||||||
|
|
||||||
const bandScore =
|
const baseBandScore =
|
||||||
moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) /
|
moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) /
|
||||||
moduleResults.length;
|
moduleResults.length;
|
||||||
|
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||||
const { correct, total } = getScoreAndTotal(moduleResults);
|
const { correct, total } = getScoreAndTotal(moduleResults);
|
||||||
const png = getRadialProgressPNG("azul", correct, total);
|
const png = getRadialProgressPNG("azul", correct, total);
|
||||||
|
|
||||||
@@ -203,7 +209,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const { correct: overallCorrect, total: overallTotal } =
|
const { correct: overallCorrect, total: overallTotal } =
|
||||||
getScoreAndTotal(flattenResults);
|
getScoreAndTotal(flattenResults);
|
||||||
const overallResult = overallCorrect / overallTotal;
|
const baseOverallResult = overallCorrect / overallTotal;
|
||||||
|
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
||||||
|
|
||||||
const overallPNG = getRadialProgressPNG(
|
const overallPNG = getRadialProgressPNG(
|
||||||
"laranja",
|
"laranja",
|
||||||
@@ -365,7 +372,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// generate the file ref for storage
|
// generate the file ref for storage
|
||||||
const fileName = `${Date.now().toString()}.pdf`;
|
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
|
// upload the pdf to storage
|
||||||
const pdfBuffer = await streamToBuffer(pdfStream);
|
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
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
await updateDoc(docSnap.ref, {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +416,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.pdf) {
|
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();
|
res.status(404).end();
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ import useGroups from "@/hooks/useGroups";
|
|||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import { BsFilePdf } from "react-icons/bs";
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
import axios from "axios";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -58,10 +56,6 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
type DownloadingPdf = {
|
|
||||||
[key: string]: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function History({user}: {user: User}) {
|
export default function History({user}: {user: User}) {
|
||||||
const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
||||||
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
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 setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
const [downloadingPdf, setDownloadingPdf] = useState<DownloadingPdf>({});
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const renderPdfIcon = usePDFDownload("stats");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stats && !isStatsLoading) {
|
if (stats && !isStatsLoading) {
|
||||||
@@ -208,28 +202,6 @@ export default function History({user}: {user: User}) {
|
|||||||
correct / total < 0.3 && "text-mti-rose",
|
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 = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
@@ -248,32 +220,7 @@ export default function History({user}: {user: User}) {
|
|||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
{/*<a
|
{renderPdfIcon(session, textColor, textColor)}
|
||||||
href="https://firebasestorage.googleapis.com/v0/b/mti-ielts.appspot.com/o/exam_report%2F1704838712225.pdf?alt=media&token=0df9a50a-05a9-40a8-ba7c-4ff10ac5f3c8"
|
|
||||||
//download="report.pdf"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<BsFilePdf
|
|
||||||
className={`${textColor} text-2xl cursor-pointer`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
triggerDownload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</a>*/}
|
|
||||||
{downloadingPdf[session] ?
|
|
||||||
<span className={`${textColor} loading loading-infinity w-6`} /> :
|
|
||||||
(
|
|
||||||
<BsFilePdf
|
|
||||||
className={`${textColor} text-2xl cursor-pointer`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
triggerDownload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,22 +22,23 @@ export const getRadialProgressPNG = (
|
|||||||
// calculate the percentage of the score
|
// calculate the percentage of the score
|
||||||
// and round it to the closest available image
|
// and round it to the closest available image
|
||||||
const percent = (score / total) * 100;
|
const percent = (score / total) * 100;
|
||||||
|
if (isNaN(percent)) return `public/radial_progress/${color}_0.png`;
|
||||||
const remainder = percent % 10;
|
const remainder = percent % 10;
|
||||||
const roundedPercent = percent - remainder;
|
const roundedPercent = percent - remainder;
|
||||||
return `public/radial_progress/${color}_${roundedPercent}.png`;
|
return `public/radial_progress/${color}_${roundedPercent}.png`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const streamToBuffer = async (
|
export const streamToBuffer = async (
|
||||||
stream: NodeJS.ReadableStream
|
stream: NodeJS.ReadableStream
|
||||||
): Promise<Buffer> => {
|
): Promise<Buffer> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
stream.on("data", (data) => {
|
stream.on("data", (data) => {
|
||||||
chunks.push(data);
|
chunks.push(data);
|
||||||
});
|
|
||||||
stream.on("end", () => {
|
|
||||||
resolve(Buffer.concat(chunks));
|
|
||||||
});
|
|
||||||
stream.on("error", reject);
|
|
||||||
});
|
});
|
||||||
};
|
stream.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user