diff --git a/package-lock.json b/package-lock.json index f77ab43..38c6d2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mhd", "version": "0.1.0", "dependencies": { + "@base-ui/react": "^1.2.0", "@floating-ui/dom": "^1.7.4", "@hookform/resolvers": "^5.2.2", "@mantine/core": "^8.3.4", @@ -367,6 +368,59 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.2.0.tgz", + "integrity": "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@base-ui/utils": "0.2.5", + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.5.tgz", + "integrity": "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@better-auth/core": { "version": "1.4.18", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", @@ -15264,6 +15318,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resend": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.1.tgz", @@ -17670,6 +17730,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", diff --git a/package.json b/package.json index 4638cec..093e9d4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prepare": "husky" }, "dependencies": { + "@base-ui/react": "^1.2.0", "@floating-ui/dom": "^1.7.4", "@hookform/resolvers": "^5.2.2", "@mantine/core": "^8.3.4", diff --git a/src/app/graph/page.tsx b/src/app/graph/page.tsx index 4c0491b..b40f7ba 100644 --- a/src/app/graph/page.tsx +++ b/src/app/graph/page.tsx @@ -16,6 +16,7 @@ import { ChevronDown, LineChart, Link, + PlusCircle, Share, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -39,7 +40,13 @@ import { parseAsString, parseAsArrayOf, } from "nuqs"; -import { downloadGraph } from "@/lib/export-to-pdf"; +import { addToCart, downloadSingleGraph } from "@/lib/export-to-pdf"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { Cart } from "@/components/Cart"; type Project = { id: number; @@ -158,6 +165,10 @@ export default function GraphsPage() { parseAsString.withDefault(""), ); + const [cart, setCart] = useState([]); + + const [filterNames, setFilterNames] = useState([]); + const filters: Filters = useMemo( () => ({ individualProjects: true, @@ -486,6 +497,29 @@ export default function GraphsPage() { new Set(allProjects.map((p) => p.category)), ).sort(); + const filterName = `Projects by ${groupByLabels[filters.groupBy]}`; + + useEffect(() => { + const cartStorage = sessionStorage.getItem("cartStorage"); + const cartNameStorage = sessionStorage.getItem("cartNameStorage"); + + if (cartStorage) { + setCart(JSON.parse(cartStorage)); + } + + if (cartNameStorage) { + setFilterNames(JSON.parse(cartNameStorage)); + } + }, []); + + useEffect(() => { + sessionStorage.setItem("cartStorage", JSON.stringify(cart)); + }, [cart]); + + useEffect(() => { + sessionStorage.setItem("cartNameStorage", JSON.stringify(filterNames)); + }, [filterNames]); + return (
{/* Left Sidebar - Filter Panel */} @@ -530,11 +564,46 @@ export default function GraphsPage() { variant="outline" size="sm" className="flex items-center gap-2" - onClick={() => downloadGraph(svgRef)} + onClick={() => downloadSingleGraph(svgRef)} > Export + + + addToCart( + svgRef, + cart, + setCart, + filterNames, + setFilterNames, + filterName, + ) + } + > + + Add to + + } + /> + + + + + +
+ ))} +
+ + +
+ + ); +} diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..35114c1 --- /dev/null +++ b/src/components/ui/hover-card.tsx @@ -0,0 +1,65 @@ +/*************************************************************** + * + * /src/components/hover-card.tsx + * + * Author: Will and Justin + * Date: 2/13/2025 + * + * Summary: Empty hover card component when hovering + * over Add to button. Filled with Cart.tsx + **************************************************************/ + +"use client"; + +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; + +import { cn } from "@/lib/utils"; + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return ; +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ( + + ); +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "start", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick< + PreviewCardPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/src/lib/export-to-pdf.ts b/src/lib/export-to-pdf.ts index 6e2b9a6..6f60ecb 100644 --- a/src/lib/export-to-pdf.ts +++ b/src/lib/export-to-pdf.ts @@ -8,11 +8,127 @@ * Summary: Export an svg graph as a pdf **************************************************************/ -import React from "react"; +import React, { Dispatch, SetStateAction } from "react"; import html2canvas from "html2canvas-pro"; import jsPDF from "jspdf"; +import logoImg from "../../public/images/logo.png"; +import { toast } from "sonner"; -export async function downloadGraph( +export function downloadGraphs(cart: string[], filterNames: string[]) { + // Displays toast when there are no images to export + if (cart.length == 0) { + toast.error("Cart is empty"); + } + + const pdf = new jsPDF(); + + // Does process for each graph in the cart + cart.forEach((canvas: string, idx: number) => { + const img = new Image(); + img.src = canvas; + + // Date data for image + const time = new Date(); + const year = String(time.getFullYear()); + const month = String(time.getMonth()); + const day = String(time.getDate()); + + img.onload = () => { + const imgWidth = pdf.internal.pageSize.getWidth(); + const imgHeight = (img.height / img.width) * imgWidth; + + pdf.setFont("Interstate", "bold"); + + pdf.text(`${month}/${day}/${year}`, 180, 15); + pdf.addImage( + logoImg.src, + "PNG", + 20, + 10, + logoImg.width * 0.03, + logoImg.height * 0.03, + ); + + pdf.text(filterNames[idx], 25, 50); + + pdf.addImage( + canvas, + "JPEG", + 10, + 55, + imgWidth * 0.9, + imgHeight * 0.9, + ); + + if (idx < cart.length - 1) pdf.addPage(); + + if (idx === cart.length - 1) pdf.save("graph.pdf"); + }; + }); +} + +export function getClonedSvg( + svgRef: React.RefObject, +): SVGSVGElement | null { + const original = svgRef.current; + if (!original) return null; + + //Creates and returns a clone of the svg element passed in + const clone = original.cloneNode(true) as SVGSVGElement; + + return clone; +} + +export async function addToCart( + svgRef: React.RefObject, + cart: string[], + setCart: Dispatch>, + filterNames: string[], + setFilterNames: Dispatch>, + filterName: string, +): Promise { + const newSVG = getClonedSvg(svgRef); + if (!newSVG) return; + + // Get SVG dimensions from viewBox or attributes with fallbacks + const viewBox = newSVG.getAttribute("viewBox"); + let svgWidth = 1000; + let svgHeight = 400; + + if (viewBox) { + const viewBoxValues = viewBox.split(" "); + svgWidth = parseFloat(viewBoxValues[2]) || 1000; + svgHeight = parseFloat(viewBoxValues[3]) || 400; + } else { + const width = newSVG.getAttribute("width"); + const height = newSVG.getAttribute("height"); + if (width) svgWidth = parseFloat(width) || 1000; + if (height) svgHeight = parseFloat(height) || 400; + } + + // Adds the svg element to the page temporarily (offscreen) + const wrapper = document.createElement("div"); + wrapper.style.position = "fixed"; + wrapper.style.left = "-9999px"; + wrapper.style.top = "-9999px"; + wrapper.style.width = `${svgWidth}px`; + wrapper.style.height = `${svgHeight}px`; + wrapper.appendChild(newSVG); + document.body.append(wrapper); + + const canvas = await html2canvas(wrapper, { + backgroundColor: "#fff", + scale: 2, + }); + + // Updates cart and filter names + setCart([...cart, canvas.toDataURL()]); + setFilterNames([...filterNames, filterName]); + + document.body.removeChild(wrapper); +} + +export async function downloadSingleGraph( svgRef: React.RefObject, ) { const newSVG = getClonedSvg(svgRef); @@ -63,14 +179,25 @@ export async function downloadGraph( document.body.removeChild(wrapper); } -export function getClonedSvg( - svgRef: React.RefObject, -): SVGSVGElement | null { - const original = svgRef.current; - if (!original) return null; - - //Creates and returns a clone of the svg element passed in - const clone = original.cloneNode(true) as SVGSVGElement; +export function clearCart( + setCart: Dispatch>, + setFilterNames: Dispatch>, +) { + // Resets cart and filter names + setCart([]); + setFilterNames([]); +} - return clone; +export function deleteFromCart( + cart: string[], + setCart: Dispatch>, + filterNames: string[], + setFilterNames: Dispatch>, + filterName: string, +) { + // Locates index where a filter occurs and removes it from cart + // NOTE: slightly buggy when there are multiple graphs with same name + var idx = filterNames.indexOf(filterName); + setCart(cart.filter((_, index) => index !== idx)); + setFilterNames(filterNames.filter((_, index) => index !== idx)); }