diff --git a/next.config.mjs b/next.config.mjs index 8c8bab6..cd67477 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + trailingSlash: true, + images: { + domains: ["127.0.0.1", "localhost"], // Allow images from 127.0.0.1 (localhost) + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 7b7e594..6b1bc99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-select": "^2.1.1", "@sanity/client": "^6.21.3", "@sanity/image-url": "^1.0.2", + "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cva": "^0.0.0", @@ -5820,8 +5821,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/autoprefixer": { "version": "10.4.20", @@ -5887,6 +5887,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -6604,7 +6615,6 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -7626,7 +7636,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.4.0" } @@ -9053,7 +9062,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "license": "MIT", - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -11988,7 +11996,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -11998,7 +12005,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -15942,6 +15948,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", diff --git a/package.json b/package.json index fadbe63..ddd3dca 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-select": "^2.1.1", "@sanity/client": "^6.21.3", "@sanity/image-url": "^1.0.2", + "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cva": "^0.0.0", diff --git a/src/app/certificates/[slug]/page.js b/src/app/certificates/[slug]/page.js new file mode 100644 index 0000000..ef1bde9 --- /dev/null +++ b/src/app/certificates/[slug]/page.js @@ -0,0 +1,76 @@ +import Image from "next/image"; +import Navbar from "@/app/components/navbar"; +import About from "@/app/components/about"; +import { Raleway, JetBrains_Mono } from "next/font/google"; +import axios from "axios"; +import { notFound } from "next/navigation"; +import BlurFade from "@/app/components/ui/blur-fade"; + +const raleway = Raleway({ subsets: ["latin"] }); +const jetbrains_mono = JetBrains_Mono({ subsets: ["latin"] }); + +export const dynamic = "force-dynamic"; // Ensure page is always server-rendered + +export default async function Home({ params }) { + const { slug } = params; + + let certificate; + + try { + // Fetch data from the server-side API + const response = await axios.get( + `http://localhost:5000/certificates/${slug}` + ); + + if (!response.data) { + notFound(); // Trigger a 404 if no certificate is found + } + + certificate = response.data; // If certificate exists, assign it + } catch (error) { + console.error("Error fetching certificate data:", error.message); + notFound(); // Trigger 404 if there is an error + } + + const imageURL = `http://127.0.0.1:5000${certificate.image}`; + const name = certificate.name; + + return ( +
+ +
+

+ ACM MPSTME +

+

+ PFE - Participation Certificate{" "} + + {certificate.subject} + +

+
+
+
+ + {`ACM's + + + + +
+
+ ); +} diff --git a/src/app/components/about.jsx b/src/app/components/about.jsx new file mode 100644 index 0000000..832ed43 --- /dev/null +++ b/src/app/components/about.jsx @@ -0,0 +1,86 @@ +"use client" +import { useState } from "react"; +import { Raleway, JetBrains_Mono } from "next/font/google"; + +const raleway = Raleway({ subsets: ['latin']}); +const jbm = JetBrains_Mono({subsets: ['latin']}); + +const About = ({ certificate }) => { + const [copySuccess, setCopySuccess] = useState(false); + + const handleCopy = () => { + const link = window.location.href; + + // Copy the link to the clipboard + navigator.clipboard.writeText(link) + .then(() => { + setCopySuccess(true); // Show success message + setTimeout(() => setCopySuccess(false), 2000); // Clear the message after 2 seconds + }) + .catch((err) => { + console.error("Failed to copy: ", err); + }); + }; + + const handleDownload = async () => { + try { + // Fetch the certificate image from the server as a Blob + const response = await fetch(`http://localhost:5000${certificate.image}`); + const blob = await response.blob(); // Convert the response into a Blob + + // Create a URL for the Blob object + const url = window.URL.createObjectURL(blob); + + // Create an invisible anchor element + const link = document.createElement('a'); + link.href = url; + link.download = `ACM_PFE - ${certificate.name}_${certificate.id}.png`; // File name for the download + + // Append the link to the DOM, trigger click, and remove it from the DOM + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Revoke the object URL to free memory + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('Error downloading the file:', error); + } + }; + + return ( +
+
+
+

About this Certificate

+

ID: {certificate.id} - {certificate.name}

+
+
+
+

PFE - Participation Certificate 2024

+

+ This certificate has been awarded to {certificate.name} for their 3 day continous participation in PFE (Programming For Everyone) where the students learnt the fudamentals of programming languages of their choice. In the workshop, the students learnt and were able to build a CLI RPG. +

+
+
+

Share this certificate

+
+

+ {window.location.href} +

+ +
+
+
+
+ + {/* */} +
+ ) +} + +export default About; \ No newline at end of file diff --git a/src/app/components/navbar.jsx b/src/app/components/navbar.jsx new file mode 100644 index 0000000..39ed069 --- /dev/null +++ b/src/app/components/navbar.jsx @@ -0,0 +1,12 @@ +import Image from "next/image"; + +const Navbar = () => { + return ( +
+ ACM MPSTME + TRC MPSTME +
+ ) +} + +export default Navbar; \ No newline at end of file diff --git a/src/app/components/ui/blur-fade.jsx b/src/app/components/ui/blur-fade.jsx new file mode 100644 index 0000000..d90d9b3 --- /dev/null +++ b/src/app/components/ui/blur-fade.jsx @@ -0,0 +1,42 @@ +"use client";; +import { useRef } from "react"; +import { AnimatePresence, motion, useInView } from "framer-motion"; + +export default function BlurFade({ + children, + className, + variant, + duration = 0.4, + delay = 0, + yOffset = 6, + inView = false, + inViewMargin = "-50px", + blur = "6px" +}) { + const ref = useRef(null); + const inViewResult = useInView(ref, { once: true, margin: inViewMargin }); + const isInView = !inView || inViewResult; + const defaultVariants = { + hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` }, + visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` }, + }; + const combinedVariants = variant || defaultVariants; + return ( + ( + + {children} + + ) + ); +} diff --git a/src/app/components/ui/flickering-grid.jsx b/src/app/components/ui/flickering-grid.jsx new file mode 100644 index 0000000..084597b --- /dev/null +++ b/src/app/components/ui/flickering-grid.jsx @@ -0,0 +1,159 @@ +"use client";; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +const FlickeringGrid = ({ + squareSize = 4, + gridGap = 6, + flickerChance = 0.3, + color = "rgb(0, 0, 0)", + width, + height, + className, + maxOpacity = 0.3, +}) => { + const canvasRef = useRef(null); + const [isInView, setIsInView] = useState(false); + + const memoizedColor = useMemo(() => { + const toRGBA = (color) => { + if (typeof window === "undefined") { + return `rgba(0, 0, 0,`; + } + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d"); + if (!ctx) return "rgba(255, 0,"; + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return `rgba(${r}, ${g}, ${b},`; + }; + return toRGBA(color); + }, [color]); + + const setupCanvas = useCallback((canvas) => { + const canvasWidth = width || canvas.clientWidth; + const canvasHeight = height || canvas.clientHeight; + const dpr = window.devicePixelRatio || 1; + canvas.width = canvasWidth * dpr; + canvas.height = canvasHeight * dpr; + canvas.style.width = `${canvasWidth}px`; + canvas.style.height = `${canvasHeight}px`; + const cols = Math.floor(canvasWidth / (squareSize + gridGap)); + const rows = Math.floor(canvasHeight / (squareSize + gridGap)); + + const squares = new Float32Array(cols * rows); + for (let i = 0; i < squares.length; i++) { + squares[i] = Math.random() * maxOpacity; + } + + return { + width: canvasWidth, + height: canvasHeight, + cols, + rows, + squares, + dpr, + }; + }, [squareSize, gridGap, width, height, maxOpacity]); + + const updateSquares = useCallback((squares, deltaTime) => { + for (let i = 0; i < squares.length; i++) { + if (Math.random() < flickerChance * deltaTime) { + squares[i] = Math.random() * maxOpacity; + } + } + }, [flickerChance, maxOpacity]); + + const drawGrid = useCallback(( + ctx, + width, + height, + cols, + rows, + squares, + dpr, + ) => { + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = "transparent"; + ctx.fillRect(0, 0, width, height); + + for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + const opacity = squares[i * rows + j]; + ctx.fillStyle = `${memoizedColor}${opacity})`; + ctx.fillRect( + i * (squareSize + gridGap) * dpr, + j * (squareSize + gridGap) * dpr, + squareSize * dpr, + squareSize * dpr + ); + } + } + }, [memoizedColor, squareSize, gridGap]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationFrameId; + let { width, height, cols, rows, squares, dpr } = setupCanvas(canvas); + + let lastTime = 0; + const animate = (time) => { + if (!isInView) return; + + const deltaTime = (time - lastTime) / 1000; + lastTime = time; + + updateSquares(squares, deltaTime); + drawGrid(ctx, width * dpr, height * dpr, cols, rows, squares, dpr); + animationFrameId = requestAnimationFrame(animate); + }; + + const handleResize = () => { + ({ width, height, cols, rows, squares, dpr } = setupCanvas(canvas)); + }; + + const observer = new IntersectionObserver(([entry]) => { + setIsInView(entry.isIntersecting); + }, { threshold: 0 }); + + observer.observe(canvas); + + window.addEventListener("resize", handleResize); + + if (isInView) { + animationFrameId = requestAnimationFrame(animate); + } + + return () => { + window.removeEventListener("resize", handleResize); + cancelAnimationFrame(animationFrameId); + observer.disconnect(); + }; + }, [setupCanvas, updateSquares, drawGrid, width, height, isInView]); + + return ( + () + ); +}; + +export default FlickeringGrid; diff --git a/src/app/components/ui/number-ticker.jsx b/src/app/components/ui/number-ticker.jsx new file mode 100644 index 0000000..4c537c8 --- /dev/null +++ b/src/app/components/ui/number-ticker.jsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useInView, useMotionValue, useSpring } from "framer-motion"; + +import { cn } from "@/lib/utils"; + +export default function NumberTicker({ + value, + direction = "up", + delay = 0, + className, + decimalPlaces = 0 +}) { + const ref = useRef(null); + const motionValue = useMotionValue(direction === "down" ? value : 0); + const springValue = useSpring(motionValue, { + damping: 60, + stiffness: 100, + }); + const isInView = useInView(ref, { once: true, margin: "0px" }); + + useEffect(() => { + isInView && + setTimeout(() => { + motionValue.set(direction === "down" ? 0 : value); + }, delay * 1000); + }, [motionValue, isInView, delay, value, direction]); + + useEffect(() => + springValue.on("change", (latest) => { + if (ref.current) { + ref.current.textContent = Intl.NumberFormat("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(Number(latest.toFixed(decimalPlaces))); + } + }), [springValue, decimalPlaces]); + + return ( + () + ); +}