diff --git a/Projects/AI-Resume-Analyser/.dockerignore b/Projects/AI-Resume-Analyser/.dockerignore new file mode 100644 index 000000000..9b8d51471 --- /dev/null +++ b/Projects/AI-Resume-Analyser/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/Projects/AI-Resume-Analyser/.gitignore b/Projects/AI-Resume-Analyser/.gitignore new file mode 100644 index 000000000..9b7c041f9 --- /dev/null +++ b/Projects/AI-Resume-Analyser/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/node_modules/ + +# React Router +/.react-router/ +/build/ diff --git a/Projects/AI-Resume-Analyser/Dockerfile b/Projects/AI-Resume-Analyser/Dockerfile new file mode 100644 index 000000000..207bf937e --- /dev/null +++ b/Projects/AI-Resume-Analyser/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/Projects/AI-Resume-Analyser/README.md b/Projects/AI-Resume-Analyser/README.md new file mode 100644 index 000000000..c4b69c844 --- /dev/null +++ b/Projects/AI-Resume-Analyser/README.md @@ -0,0 +1,76 @@ +**Ai-Resume-Analyser-For-ROSPL** + +**INTRODUCTION** + +Build an AI-powered Resume Analyzer with React, React Router, and Puter.js! Implement seamless auth, upload and store resumes, and match candidates to jobs using smart AI evaluations. Get custom feedback and ATS scores tailored to each listing—all wrapped in a clean, reusable UI. + +## ⚙️ Tech Stack + +- **[React](https://react.dev/)** is a popular open‑source JavaScript library for building user interfaces using reusable components and a virtual DOM, enabling efficient, dynamic single-page and native apps. + +- **[React Router v7](https://reactrouter.com/)** is the go‑to routing library for React apps, offering nested routes, data loaders/actions, error boundaries, code splitting, and SSR support—all with a smooth upgrade path from v6. + +- **[Puter.com](https://jsm.dev/resumind-puter)** is an advanced, open-source internet operating system designed to be feature-rich, exceptionally fast, and highly extensible. Puter can be used as: A privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time. + +- **[Puter.js](https://jsm.dev/resumind-puterjs)** is a tiny client‑side SDK that adds serverless auth, storage, database, and AI (GPT, Claude, DALL·E, OCR…) straight into your browser app—no backend needed and costs borne by users. + +- **[Tailwind CSS](https://tailwindcss.com/)** is a utility-first CSS framework that allows developers to design custom user interfaces by applying low-level utility classes directly in HTML, streamlining the design process. + +- **[TypeScript](https://www.typescriptlang.org/)** is a superset of JavaScript that adds static typing, providing better tooling, code quality, and error detection for developers, making it ideal for building large-scale applications. + +- **[Vite](https://vite.dev/)** is a fast build tool and dev server using native ES modules for instant startup, hot‑module replacement, and Rollup‑powered production builds—perfect for modern web development. + +- **[Zustand](https://github.com/pmndrs/zustand)** is a minimal, hook-based state management library for React. It lets you manage global state with zero boilerplate, no context providers, and excellent performance through selective state subscriptions. + +**FEATURES** + +**Easy & convenient auth**: Handle authentication entirely in the browser using Puter.js—no backend or setup required. + +**Resume upload & storage**: Let users upload and store all their resumes in one place, safely and reliably. + +**AI resume matching**: Provide a job listing and get an ATS score with custom feedback tailored to each resume. + +**Reusable, modern UI**: Built with clean, consistent components for a great-looking and maintainable interface. + +**Code Reusability**: Leverage reusable components and a modular codebase for efficient development. + +**Cross-Device Compatibility**: Fully responsive design that works seamlessly across all devices. + +**Modern UI/UX**: Clean, responsive design built with Tailwind CSS and shadcn/ui for a sleek user experience. + +And many more, including code architecture and reusability. + +## 🤸 Quick Start + +Follow these steps to set up the project locally on your machine. + +**Prerequisites** + +Make sure you have the following installed on your machine: + +- [Git](https://git-scm.com/) +- [Node.js](https://nodejs.org/en) +- [npm](https://www.npmjs.com/) (Node Package Manager) + +**Cloning the Repository** + +```bash +git clone https://github.com/adrianhajdin/ai-resume-analyzer.git +cd ai-resume-analyzer +``` + +**Installation** + +Install the project dependencies using npm: + +```bash +npm install +``` + +**Running the Project** + +```bash +npm run dev +``` + +Open [http://localhost:5173](http://localhost:5173) in your browser to view the project. diff --git a/Projects/AI-Resume-Analyser/app/app.css b/Projects/AI-Resume-Analyser/app/app.css new file mode 100644 index 000000000..6da9b3aeb --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/app.css @@ -0,0 +1,152 @@ +@import url("https://fonts.googleapis.com/css2?family=Mona+Sans:ital,wght@0,200..900;1,200..900&display=swap"); +@import "tailwindcss"; +@import "tw-animate-css"; + +@theme { + --font-sans: "Mona Sans", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --color-dark-200: #475467; + --color-light-blue-100: #c1d3f81a; + --color-light-blue-200: #a7bff14d; + + --color-badge-green: #d5faf1; + --color-badge-red: #f9e3e2; + --color-badge-yellow: #fceed8; + + --color-badge-green-text: #254d4a; + --color-badge-red-text: #752522; + --color-badge-yellow-text: #73321b; +} + +html, +body { + @apply bg-white; +} + +main { + @apply min-h-screen pt-10; +} +h1 { + @apply text-6xl text-gradient leading-tight tracking-[-2px] font-semibold; +} + +h2 { + @apply text-3xl text-dark-200; +} + +label { + @apply text-dark-200; +} +input { + @apply w-full p-4 inset-shadow rounded-2xl focus:outline-none bg-white; +} + +textarea { + @apply w-full p-4 inset-shadow rounded-2xl focus:outline-none bg-white; +} + +form { + @apply flex flex-col items-start gap-8 w-full; +} + +@layer components { + .text-gradient { + @apply bg-clip-text text-transparent bg-gradient-to-r from-[#AB8C95] via-[#000000] to-[#8E97C5]; + } + .gradient-border { + @apply bg-gradient-to-b from-light-blue-100 to-light-blue-200 p-4 rounded-2xl; + } + .primary-button { + @apply primary-gradient text-white rounded-full px-4 py-2 cursor-pointer w-full; + } + .resume-nav { + @apply flex flex-row justify-between items-center p-4 border-b border-gray-200; + } + .resume-summary { + @apply flex flex-row items-center justify-center p-4 gap-4; + .category { + @apply flex flex-row gap-2 items-center bg-gray-50 rounded-2xl p-4 w-full justify-between; + } + } + .back-button { + @apply flex flex-row items-center gap-2 border border-gray-200 rounded-lg p-2 shadow-sm; + } + .auth-button { + @apply primary-gradient rounded-full py-4 px-8 cursor-pointer w-[600px] max-md:w-full text-3xl font-semibold text-white; + } + .main-section { + @apply flex flex-col items-center gap-8 pt-12 mx-15 pb-5; + } + .page-heading { + @apply flex flex-col items-center gap-8 max-w-4xl text-center; + } + .resumes-section { + @apply flex flex-wrap max-lg:flex-col gap-6 items-start w-full max-w-[1850px] justify-evenly; + } + + .resume-card { + @apply flex flex-col gap-8 h-[560px] w-full lg:w-[450px] xl:w-[490px] bg-white rounded-2xl p-4; + } + + .resume-card-header { + @apply flex flex-row gap-2 justify-between min-h-[110px] max-sm:flex-col items-center max-md:justify-center max-md:items-center; + } + + .feedback-section { + @apply flex flex-col gap-8 w-1/2 px-8 max-lg:w-full py-6; + } + + .navbar { + @apply flex flex-row justify-between items-center bg-white rounded-full p-4 w-full px-10 max-w-[1200px] mx-auto; + } + + .score-badge { + @apply flex flex-row items-center justify-center py-1 px-2 gap-4 rounded-[96px]; + } + + .form-div { + @apply flex flex-col gap-2 w-full items-start; + } + + .uplader-drag-area { + @apply relative p-8 text-center transition-all duration-700 cursor-pointer bg-white rounded-2xl min-h-[208px]; + } + .uploader-selected-file { + @apply flex items-center justify-between p-3 bg-gray-50 rounded-2xl; + } +} + +@utility bg-gradient { + background: linear-gradient(to bottom, #f0f4ff 60%, #fa7185cc); +} + +@utility text-gradient { + @apply bg-clip-text text-transparent bg-gradient-to-r from-[#AB8C95] via-[#000000] to-[#8E97C5]; +} + +@utility gradient-hover { + @apply bg-gradient-to-b from-light-blue-100 to-light-blue-200; +} + +@utility primary-gradient { + background: linear-gradient(to bottom, #8e98ff, #606beb); + box-shadow: 0px 74px 21px 0px #6678ef00; +} + +@utility primary-gradient-hover { + background: linear-gradient(to bottom, #717dff, #4957eb); +} + +@utility inset-shadow { + box-shadow: inset 0 0 12px 0 rgba(36, 99, 235, 0.2); + backdrop-filter: blur(10px); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/Projects/AI-Resume-Analyser/app/components/ATS.tsx b/Projects/AI-Resume-Analyser/app/components/ATS.tsx new file mode 100644 index 000000000..d5c507057 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/ATS.tsx @@ -0,0 +1,77 @@ +import React from 'react' + +interface Suggestion { + type: "good" | "improve"; + tip: string; +} + +interface ATSProps { + score: number; + suggestions: Suggestion[]; +} + +const ATS: React.FC = ({ score, suggestions }) => { + // Determine background gradient based on score + const gradientClass = score > 69 + ? 'from-green-100' + : score > 49 + ? 'from-yellow-100' + : 'from-red-100'; + + // Determine icon based on score + const iconSrc = score > 69 + ? '/icons/ats-good.svg' + : score > 49 + ? '/icons/ats-warning.svg' + : '/icons/ats-bad.svg'; + + // Determine subtitle based on score + const subtitle = score > 69 + ? 'Great Job!' + : score > 49 + ? 'Good Start' + : 'Needs Improvement'; + + return ( +
+ {/* Top section with icon and headline */} +
+ ATS Score Icon +
+

ATS Score - {score}/100

+
+
+ + {/* Description section */} +
+

{subtitle}

+

+ This score represents how well your resume is likely to perform in Applicant Tracking Systems used by employers. +

+ + {/* Suggestions list */} +
+ {suggestions.map((suggestion, index) => ( +
+ {suggestion.type +

+ {suggestion.tip} +

+
+ ))} +
+
+ + {/* Closing encouragement */} +

+ Keep refining your resume to improve your chances of getting past ATS filters and into the hands of recruiters. +

+
+ ) +} + +export default ATS diff --git a/Projects/AI-Resume-Analyser/app/components/Accordion.tsx b/Projects/AI-Resume-Analyser/app/components/Accordion.tsx new file mode 100644 index 000000000..7cb896ce9 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/Accordion.tsx @@ -0,0 +1,166 @@ +import type { ReactNode } from "react"; +import React, { createContext, useContext, useState } from "react"; +import { cn } from "~/lib/utils"; + +interface AccordionContextType { + activeItems: string[]; + toggleItem: (id: string) => void; + isItemActive: (id: string) => boolean; +} + +const AccordionContext = createContext( + undefined +); + +const useAccordion = () => { + const context = useContext(AccordionContext); + if (!context) { + throw new Error("Accordion components must be used within an Accordion"); + } + return context; +}; + +interface AccordionProps { + children: ReactNode; + defaultOpen?: string; + allowMultiple?: boolean; + className?: string; +} + +export const Accordion: React.FC = ({ + children, + defaultOpen, + allowMultiple = false, + className = "", + }) => { + const [activeItems, setActiveItems] = useState( + defaultOpen ? [defaultOpen] : [] + ); + + const toggleItem = (id: string) => { + setActiveItems((prev) => { + if (allowMultiple) { + return prev.includes(id) + ? prev.filter((item) => item !== id) + : [...prev, id]; + } else { + return prev.includes(id) ? [] : [id]; + } + }); + }; + + const isItemActive = (id: string) => activeItems.includes(id); + + return ( + +
{children}
+
+ ); +}; + +interface AccordionItemProps { + id: string; + children: ReactNode; + className?: string; +} + +export const AccordionItem: React.FC = ({ + id, + children, + className = "", + }) => { + return ( +
+ {children} +
+ ); +}; + +interface AccordionHeaderProps { + itemId: string; + children: ReactNode; + className?: string; + icon?: ReactNode; + iconPosition?: "left" | "right"; +} + +export const AccordionHeader: React.FC = ({ + itemId, + children, + className = "", + icon, + iconPosition = "right", + }) => { + const { toggleItem, isItemActive } = useAccordion(); + const isActive = isItemActive(itemId); + + const defaultIcon = ( + + + + ); + + const handleClick = () => { + toggleItem(itemId); + }; + + return ( + + ); +}; + +interface AccordionContentProps { + itemId: string; + children: ReactNode; + className?: string; +} + +export const AccordionContent: React.FC = ({ + itemId, + children, + className = "", + }) => { + const { isItemActive } = useAccordion(); + const isActive = isItemActive(itemId); + + return ( +
+
{children}
+
+ ); +}; diff --git a/Projects/AI-Resume-Analyser/app/components/Details.tsx b/Projects/AI-Resume-Analyser/app/components/Details.tsx new file mode 100644 index 000000000..c13f1928e --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/Details.tsx @@ -0,0 +1,162 @@ +import { cn } from "~/lib/utils"; +import { + Accordion, + AccordionContent, + AccordionHeader, + AccordionItem, +} from "./Accordion"; + +const ScoreBadge = ({ score }: { score: number }) => { + return ( +
69 + ? "bg-badge-green" + : score > 39 + ? "bg-badge-yellow" + : "bg-badge-red" + )} + > + 69 ? "/icons/check.svg" : "/icons/warning.svg"} + alt="score" + className="size-4" + /> +

69 + ? "text-badge-green-text" + : score > 39 + ? "text-badge-yellow-text" + : "text-badge-red-text" + )} + > + {score}/100 +

+
+ ); +}; + +const CategoryHeader = ({ + title, + categoryScore, + }: { + title: string; + categoryScore: number; +}) => { + return ( +
+

{title}

+ +
+ ); +}; + +const CategoryContent = ({ + tips, + }: { + tips: { type: "good" | "improve"; tip: string; explanation: string }[]; +}) => { + return ( +
+
+ {tips.map((tip, index) => ( +
+ score +

{tip.tip}

+
+ ))} +
+
+ {tips.map((tip, index) => ( +
+
+ score +

{tip.tip}

+
+

{tip.explanation}

+
+ ))} +
+
+ ); +}; + +const Details = ({ feedback }: { feedback: Feedback }) => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default Details; diff --git a/Projects/AI-Resume-Analyser/app/components/FileUploader.tsx b/Projects/AI-Resume-Analyser/app/components/FileUploader.tsx new file mode 100644 index 000000000..0d90da90b --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/FileUploader.tsx @@ -0,0 +1,72 @@ +import {useState, useCallback} from 'react' +import {useDropzone} from 'react-dropzone' +import { formatSize } from '../lib/utils' + +interface FileUploaderProps { + onFileSelect?: (file: File | null) => void; +} + +const FileUploader = ({ onFileSelect }: FileUploaderProps) => { + const onDrop = useCallback((acceptedFiles: File[]) => { + const file = acceptedFiles[0] || null; + + onFileSelect?.(file); + }, [onFileSelect]); + + const maxFileSize = 20 * 1024 * 1024; // 20MB in bytes + + const {getRootProps, getInputProps, isDragActive, acceptedFiles} = useDropzone({ + onDrop, + multiple: false, + accept: { 'application/pdf': ['.pdf']}, + maxSize: maxFileSize, + }) + + const file = acceptedFiles[0] || null; + + + + return ( +
+
+ + +
+ {file ? ( +
e.stopPropagation()}> + pdf +
+
+

+ {file.name} +

+

+ {formatSize(file.size)} +

+
+
+ +
+ ): ( +
+
+ upload +
+

+ + Click to upload + or drag and drop +

+

PDF (max {formatSize(maxFileSize)})

+
+ )} +
+
+
+ ) +} +export default FileUploader diff --git a/Projects/AI-Resume-Analyser/app/components/Navbar.tsx b/Projects/AI-Resume-Analyser/app/components/Navbar.tsx new file mode 100644 index 000000000..1449f2c76 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/Navbar.tsx @@ -0,0 +1,15 @@ +import {Link} from "react-router"; + +const Navbar = () => { + return ( + + ) +} +export default Navbar diff --git a/Projects/AI-Resume-Analyser/app/components/ResumeCard.tsx b/Projects/AI-Resume-Analyser/app/components/ResumeCard.tsx new file mode 100644 index 000000000..19a2386f2 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/ResumeCard.tsx @@ -0,0 +1,47 @@ +import {Link} from "react-router"; +import ScoreCircle from "~/components/ScoreCircle"; +import {useEffect, useState} from "react"; +import {usePuterStore} from "~/lib/puter"; + +const ResumeCard = ({ resume: { id, companyName, jobTitle, feedback, imagePath } }: { resume: Resume }) => { + const { fs } = usePuterStore(); + const [resumeUrl, setResumeUrl] = useState(''); + + useEffect(() => { + const loadResume = async () => { + const blob = await fs.read(imagePath); + if(!blob) return; + let url = URL.createObjectURL(blob); + setResumeUrl(url); + } + + loadResume(); + }, [imagePath]); + + return ( + +
+
+ {companyName &&

{companyName}

} + {jobTitle &&

{jobTitle}

} + {!companyName && !jobTitle &&

Resume

} +
+
+ +
+
+ {resumeUrl && ( +
+
+ resume +
+
+ )} + + ) +} +export default ResumeCard diff --git a/Projects/AI-Resume-Analyser/app/components/ScoreBadge.tsx b/Projects/AI-Resume-Analyser/app/components/ScoreBadge.tsx new file mode 100644 index 000000000..1e38cf1eb --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/ScoreBadge.tsx @@ -0,0 +1,27 @@ +interface ScoreBadgeProps { + score: number; +} + +const ScoreBadge: React.FC = ({ score }) => { + let badgeColor = ''; + let badgeText = ''; + + if (score > 70) { + badgeColor = 'bg-badge-green text-green-600'; + badgeText = 'Strong'; + } else if (score > 49) { + badgeColor = 'bg-badge-yellow text-yellow-600'; + badgeText = 'Good Start'; + } else { + badgeColor = 'bg-badge-red text-red-600'; + badgeText = 'Needs Work'; + } + + return ( +
+

{badgeText}

+
+ ); +}; + +export default ScoreBadge; diff --git a/Projects/AI-Resume-Analyser/app/components/ScoreCircle.tsx b/Projects/AI-Resume-Analyser/app/components/ScoreCircle.tsx new file mode 100644 index 000000000..d044c4c40 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/ScoreCircle.tsx @@ -0,0 +1,54 @@ +const ScoreCircle = ({ score = 75 }: { score: number }) => { + const radius = 40; + const stroke = 8; + const normalizedRadius = radius - stroke / 2; + const circumference = 2 * Math.PI * normalizedRadius; + const progress = score / 100; + const strokeDashoffset = circumference * (1 - progress); + + return ( +
+ + {/* Background circle */} + + {/* Partial circle with gradient */} + + + + + + + + + + {/* Score and issues */} +
+ {`${score}/100`} +
+
+ ); +}; + +export default ScoreCircle; diff --git a/Projects/AI-Resume-Analyser/app/components/ScoreGauge.tsx b/Projects/AI-Resume-Analyser/app/components/ScoreGauge.tsx new file mode 100644 index 000000000..eb1eded1f --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/ScoreGauge.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from "react"; + +const ScoreGauge = ({ score = 75 }: { score: number }) => { + const [pathLength, setPathLength] = useState(0); + const pathRef = useRef(null); + + const percentage = score / 100; + + useEffect(() => { + if (pathRef.current) { + setPathLength(pathRef.current.getTotalLength()); + } + }, []); + + return ( +
+
+ + + + + + + + + {/* Background arc */} + + + {/* Foreground arc with rounded ends */} + + + +
+
{score}/100
+
+
+
+ ); +}; + +export default ScoreGauge; diff --git a/Projects/AI-Resume-Analyser/app/components/Summary.tsx b/Projects/AI-Resume-Analyser/app/components/Summary.tsx new file mode 100644 index 000000000..d76dac40a --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/components/Summary.tsx @@ -0,0 +1,45 @@ +import ScoreGauge from "~/components/ScoreGauge"; +import ScoreBadge from "~/components/ScoreBadge"; + +const Category = ({ title, score }: { title: string, score: number }) => { + const textColor = score > 70 ? 'text-green-600' + : score > 49 + ? 'text-yellow-600' : 'text-red-600'; + + return ( +
+
+
+

{title}

+ +
+

+ {score}/100 +

+
+
+ ) +} + +const Summary = ({ feedback }: { feedback: Feedback }) => { + return ( +
+
+ + +
+

Your Resume Score

+

+ This score is calculated based on the variables listed below. +

+
+
+ + + + + +
+ ) +} +export default Summary diff --git a/Projects/AI-Resume-Analyser/app/lib/pdf2img.ts b/Projects/AI-Resume-Analyser/app/lib/pdf2img.ts new file mode 100644 index 000000000..569d6a7c6 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/lib/pdf2img.ts @@ -0,0 +1,85 @@ +export interface PdfConversionResult { + imageUrl: string; + file: File | null; + error?: string; +} + +let pdfjsLib: any = null; +let isLoading = false; +let loadPromise: Promise | null = null; + +async function loadPdfJs(): Promise { + if (pdfjsLib) return pdfjsLib; + if (loadPromise) return loadPromise; + + isLoading = true; + // @ts-expect-error - pdfjs-dist/build/pdf.mjs is not a module + loadPromise = import("pdfjs-dist/build/pdf.mjs").then((lib) => { + // Set the worker source to use local file + lib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; + pdfjsLib = lib; + isLoading = false; + return lib; + }); + + return loadPromise; +} + +export async function convertPdfToImage( + file: File +): Promise { + try { + const lib = await loadPdfJs(); + + const arrayBuffer = await file.arrayBuffer(); + const pdf = await lib.getDocument({ data: arrayBuffer }).promise; + const page = await pdf.getPage(1); + + const viewport = page.getViewport({ scale: 4 }); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + + canvas.width = viewport.width; + canvas.height = viewport.height; + + if (context) { + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + } + + await page.render({ canvasContext: context!, viewport }).promise; + + return new Promise((resolve) => { + canvas.toBlob( + (blob) => { + if (blob) { + // Create a File from the blob with the same name as the pdf + const originalName = file.name.replace(/\.pdf$/i, ""); + const imageFile = new File([blob], `${originalName}.png`, { + type: "image/png", + }); + + resolve({ + imageUrl: URL.createObjectURL(blob), + file: imageFile, + }); + } else { + resolve({ + imageUrl: "", + file: null, + error: "Failed to create image blob", + }); + } + }, + "image/png", + 1.0 + ); // Set quality to maximum (1.0) + }); + } catch (err) { + return { + imageUrl: "", + file: null, + error: `Failed to convert PDF: ${err}`, + }; + } +} diff --git a/Projects/AI-Resume-Analyser/app/lib/puter.ts b/Projects/AI-Resume-Analyser/app/lib/puter.ts new file mode 100644 index 000000000..4a5c152bb --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/lib/puter.ts @@ -0,0 +1,456 @@ +import { create } from "zustand"; + +declare global { + interface Window { + puter: { + auth: { + getUser: () => Promise; + isSignedIn: () => Promise; + signIn: () => Promise; + signOut: () => Promise; + }; + fs: { + write: ( + path: string, + data: string | File | Blob + ) => Promise; + read: (path: string) => Promise; + upload: (file: File[] | Blob[]) => Promise; + delete: (path: string) => Promise; + readdir: (path: string) => Promise; + }; + ai: { + chat: ( + prompt: string | ChatMessage[], + imageURL?: string | PuterChatOptions, + testMode?: boolean, + options?: PuterChatOptions + ) => Promise; + img2txt: ( + image: string | File | Blob, + testMode?: boolean + ) => Promise; + }; + kv: { + get: (key: string) => Promise; + set: (key: string, value: string) => Promise; + delete: (key: string) => Promise; + list: (pattern: string, returnValues?: boolean) => Promise; + flush: () => Promise; + }; + }; + } +} + +interface PuterStore { + isLoading: boolean; + error: string | null; + puterReady: boolean; + auth: { + user: PuterUser | null; + isAuthenticated: boolean; + signIn: () => Promise; + signOut: () => Promise; + refreshUser: () => Promise; + checkAuthStatus: () => Promise; + getUser: () => PuterUser | null; + }; + fs: { + write: ( + path: string, + data: string | File | Blob + ) => Promise; + read: (path: string) => Promise; + upload: (file: File[] | Blob[]) => Promise; + delete: (path: string) => Promise; + readDir: (path: string) => Promise; + }; + ai: { + chat: ( + prompt: string | ChatMessage[], + imageURL?: string | PuterChatOptions, + testMode?: boolean, + options?: PuterChatOptions + ) => Promise; + feedback: ( + path: string, + message: string + ) => Promise; + img2txt: ( + image: string | File | Blob, + testMode?: boolean + ) => Promise; + }; + kv: { + get: (key: string) => Promise; + set: (key: string, value: string) => Promise; + delete: (key: string) => Promise; + list: ( + pattern: string, + returnValues?: boolean + ) => Promise; + flush: () => Promise; + }; + + init: () => void; + clearError: () => void; +} + +const getPuter = (): typeof window.puter | null => + typeof window !== "undefined" && window.puter ? window.puter : null; + +export const usePuterStore = create((set, get) => { + const setError = (msg: string) => { + set({ + error: msg, + isLoading: false, + auth: { + user: null, + isAuthenticated: false, + signIn: get().auth.signIn, + signOut: get().auth.signOut, + refreshUser: get().auth.refreshUser, + checkAuthStatus: get().auth.checkAuthStatus, + getUser: get().auth.getUser, + }, + }); + }; + + const checkAuthStatus = async (): Promise => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return false; + } + + set({ isLoading: true, error: null }); + + try { + const isSignedIn = await puter.auth.isSignedIn(); + if (isSignedIn) { + const user = await puter.auth.getUser(); + set({ + auth: { + user, + isAuthenticated: true, + signIn: get().auth.signIn, + signOut: get().auth.signOut, + refreshUser: get().auth.refreshUser, + checkAuthStatus: get().auth.checkAuthStatus, + getUser: () => user, + }, + isLoading: false, + }); + return true; + } else { + set({ + auth: { + user: null, + isAuthenticated: false, + signIn: get().auth.signIn, + signOut: get().auth.signOut, + refreshUser: get().auth.refreshUser, + checkAuthStatus: get().auth.checkAuthStatus, + getUser: () => null, + }, + isLoading: false, + }); + return false; + } + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to check auth status"; + setError(msg); + return false; + } + }; + + const signIn = async (): Promise => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + + set({ isLoading: true, error: null }); + + try { + await puter.auth.signIn(); + await checkAuthStatus(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Sign in failed"; + setError(msg); + } + }; + + const signOut = async (): Promise => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + + set({ isLoading: true, error: null }); + + try { + await puter.auth.signOut(); + set({ + auth: { + user: null, + isAuthenticated: false, + signIn: get().auth.signIn, + signOut: get().auth.signOut, + refreshUser: get().auth.refreshUser, + checkAuthStatus: get().auth.checkAuthStatus, + getUser: () => null, + }, + isLoading: false, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Sign out failed"; + setError(msg); + } + }; + + const refreshUser = async (): Promise => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + + set({ isLoading: true, error: null }); + + try { + const user = await puter.auth.getUser(); + set({ + auth: { + user, + isAuthenticated: true, + signIn: get().auth.signIn, + signOut: get().auth.signOut, + refreshUser: get().auth.refreshUser, + checkAuthStatus: get().auth.checkAuthStatus, + getUser: () => user, + }, + isLoading: false, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to refresh user"; + setError(msg); + } + }; + + const init = (): void => { + const puter = getPuter(); + if (puter) { + set({ puterReady: true }); + checkAuthStatus(); + return; + } + + const interval = setInterval(() => { + if (getPuter()) { + clearInterval(interval); + set({ puterReady: true }); + checkAuthStatus(); + } + }, 100); + + setTimeout(() => { + clearInterval(interval); + if (!getPuter()) { + setError("Puter.js failed to load within 10 seconds"); + } + }, 10000); + }; + + const write = async (path: string, data: string | File | Blob) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.fs.write(path, data); + }; + + const readDir = async (path: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.fs.readdir(path); + }; + + const readFile = async (path: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.fs.read(path); + }; + + const upload = async (files: File[] | Blob[]) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.fs.upload(files); + }; + + const deleteFile = async (path: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.fs.delete(path); + }; + + const chat = async ( + prompt: string | ChatMessage[], + imageURL?: string | PuterChatOptions, + testMode?: boolean, + options?: PuterChatOptions + ) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + // return puter.ai.chat(prompt, imageURL, testMode, options); + return puter.ai.chat(prompt, imageURL, testMode, options) as Promise< + AIResponse | undefined + >; + }; + + const feedback = async (path: string, message: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + + return puter.ai.chat( + [ + { + role: "user", + content: [ + { + type: "file", + puter_path: path, + }, + { + type: "text", + text: message, + }, + ], + }, + ], + { model: "claude-3-7-sonnet" } + ) as Promise; + }; + + const img2txt = async (image: string | File | Blob, testMode?: boolean) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.ai.img2txt(image, testMode); + }; + + const getKV = async (key: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.kv.get(key); + }; + + const setKV = async (key: string, value: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.kv.set(key, value); + }; + + const deleteKV = async (key: string) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.kv.delete(key); + }; + + const listKV = async (pattern: string, returnValues?: boolean) => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + if (returnValues === undefined) { + returnValues = false; + } + return puter.kv.list(pattern, returnValues); + }; + + const flushKV = async () => { + const puter = getPuter(); + if (!puter) { + setError("Puter.js not available"); + return; + } + return puter.kv.flush(); + }; + + return { + isLoading: true, + error: null, + puterReady: false, + auth: { + user: null, + isAuthenticated: false, + signIn, + signOut, + refreshUser, + checkAuthStatus, + getUser: () => get().auth.user, + }, + fs: { + write: (path: string, data: string | File | Blob) => write(path, data), + read: (path: string) => readFile(path), + readDir: (path: string) => readDir(path), + upload: (files: File[] | Blob[]) => upload(files), + delete: (path: string) => deleteFile(path), + }, + ai: { + chat: ( + prompt: string | ChatMessage[], + imageURL?: string | PuterChatOptions, + testMode?: boolean, + options?: PuterChatOptions + ) => chat(prompt, imageURL, testMode, options), + feedback: (path: string, message: string) => feedback(path, message), + img2txt: (image: string | File | Blob, testMode?: boolean) => + img2txt(image, testMode), + }, + kv: { + get: (key: string) => getKV(key), + set: (key: string, value: string) => setKV(key, value), + delete: (key: string) => deleteKV(key), + list: (pattern: string, returnValues?: boolean) => + listKV(pattern, returnValues), + flush: () => flushKV(), + }, + init, + clearError: () => set({ error: null }), + }; +}); diff --git a/Projects/AI-Resume-Analyser/app/lib/utils.ts b/Projects/AI-Resume-Analyser/app/lib/utils.ts new file mode 100644 index 000000000..aaf4060eb --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/lib/utils.ts @@ -0,0 +1,22 @@ +import {type ClassValue, clsx} from "clsx"; +import {twMerge} from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function formatSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + + // Determine the appropriate unit by calculating the log + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + // Format with 2 decimal places and round + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export const generateUUID = () => crypto.randomUUID(); + diff --git a/Projects/AI-Resume-Analyser/app/root.tsx b/Projects/AI-Resume-Analyser/app/root.tsx new file mode 100644 index 000000000..689616f46 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/root.tsx @@ -0,0 +1,84 @@ +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +import type { Route } from "./+types/root"; +import "./app.css"; +import {usePuterStore} from "~/lib/puter"; +import {useEffect} from "react"; + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + const { init } = usePuterStore(); + + useEffect(() => { + init() + }, [init]); + + return ( + + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/Projects/AI-Resume-Analyser/app/routes.ts b/Projects/AI-Resume-Analyser/app/routes.ts new file mode 100644 index 000000000..f7ec88f34 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/routes.ts @@ -0,0 +1,9 @@ +import {type RouteConfig, index, route} from "@react-router/dev/routes"; + +export default [ + index("routes/home.tsx"), + route('/auth', 'routes/auth.tsx'), + route('/upload', 'routes/upload.tsx'), + route('/resume/:id', 'routes/resume.tsx'), + route('/wipe', 'routes/wipe.tsx'), +] satisfies RouteConfig; diff --git a/Projects/AI-Resume-Analyser/app/routes/auth.tsx b/Projects/AI-Resume-Analyser/app/routes/auth.tsx new file mode 100644 index 000000000..6d7e8517d --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/routes/auth.tsx @@ -0,0 +1,53 @@ +import {usePuterStore} from "~/lib/puter"; +import {useEffect} from "react"; +import {useLocation, useNavigate} from "react-router"; + +export const meta = () => ([ + { title: 'Resumind | Auth' }, + { name: 'description', content: 'Log into your account' }, +]) + +const Auth = () => { + const { isLoading, auth } = usePuterStore(); + const location = useLocation(); + const next = location.search.split('next=')[1]; + const navigate = useNavigate(); + + useEffect(() => { + if(auth.isAuthenticated) navigate(next); + }, [auth.isAuthenticated, next]) + + return ( +
+
+
+
+

Welcome

+

Log In to Continue Your Job Journey

+
+
+ {isLoading ? ( + + ) : ( + <> + {auth.isAuthenticated ? ( + + ) : ( + + )} + + )} +
+
+
+
+ ) +} + +export default Auth diff --git a/Projects/AI-Resume-Analyser/app/routes/home.tsx b/Projects/AI-Resume-Analyser/app/routes/home.tsx new file mode 100644 index 000000000..b1601b7bf --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/routes/home.tsx @@ -0,0 +1,77 @@ +import type { Route } from "./+types/home"; +import Navbar from "~/components/Navbar"; +import ResumeCard from "~/components/ResumeCard"; +import {usePuterStore} from "~/lib/puter"; +import {Link, useNavigate} from "react-router"; +import {useEffect, useState} from "react"; + +export function meta({}: Route.MetaArgs) { + return [ + { title: "Resumind" }, + { name: "description", content: "Smart feedback for your dream job!" }, + ]; +} + +export default function Home() { + const { auth, kv } = usePuterStore(); + const navigate = useNavigate(); + const [resumes, setResumes] = useState([]); + const [loadingResumes, setLoadingResumes] = useState(false); + + useEffect(() => { + if(!auth.isAuthenticated) navigate('/auth?next=/'); + }, [auth.isAuthenticated]) + + useEffect(() => { + const loadResumes = async () => { + setLoadingResumes(true); + + const resumes = (await kv.list('resume:*', true)) as KVItem[]; + + const parsedResumes = resumes?.map((resume) => ( + JSON.parse(resume.value) as Resume + )) + + setResumes(parsedResumes || []); + setLoadingResumes(false); + } + + loadResumes() + }, []); + + return
+ + +
+
+

Track Your Applications & Resume Ratings

+ {!loadingResumes && resumes?.length === 0 ? ( +

No resumes found. Upload your first resume to get feedback.

+ ): ( +

Review your submissions and check AI-powered feedback.

+ )} +
+ {loadingResumes && ( +
+ +
+ )} + + {!loadingResumes && resumes.length > 0 && ( +
+ {resumes.map((resume) => ( + + ))} +
+ )} + + {!loadingResumes && resumes?.length === 0 && ( +
+ + Upload Resume + +
+ )} +
+
+} diff --git a/Projects/AI-Resume-Analyser/app/routes/resume.tsx b/Projects/AI-Resume-Analyser/app/routes/resume.tsx new file mode 100644 index 000000000..d8b806e32 --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/routes/resume.tsx @@ -0,0 +1,90 @@ +import {Link, useNavigate, useParams} from "react-router"; +import {useEffect, useState} from "react"; +import {usePuterStore} from "~/lib/puter"; +import Summary from "~/components/Summary"; +import ATS from "~/components/ATS"; +import Details from "~/components/Details"; + +export const meta = () => ([ + { title: 'Resumind | Review ' }, + { name: 'description', content: 'Detailed overview of your resume' }, +]) + +const Resume = () => { + const { auth, isLoading, fs, kv } = usePuterStore(); + const { id } = useParams(); + const [imageUrl, setImageUrl] = useState(''); + const [resumeUrl, setResumeUrl] = useState(''); + const [feedback, setFeedback] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + if(!isLoading && !auth.isAuthenticated) navigate(`/auth?next=/resume/${id}`); + }, [isLoading]) + + useEffect(() => { + const loadResume = async () => { + const resume = await kv.get(`resume:${id}`); + + if(!resume) return; + + const data = JSON.parse(resume); + + const resumeBlob = await fs.read(data.resumePath); + if(!resumeBlob) return; + + const pdfBlob = new Blob([resumeBlob], { type: 'application/pdf' }); + const resumeUrl = URL.createObjectURL(pdfBlob); + setResumeUrl(resumeUrl); + + const imageBlob = await fs.read(data.imagePath); + if(!imageBlob) return; + const imageUrl = URL.createObjectURL(imageBlob); + setImageUrl(imageUrl); + + setFeedback(data.feedback); + console.log({resumeUrl, imageUrl, feedback: data.feedback }); + } + + loadResume(); + }, [id]); + + return ( +
+ +
+
+ {imageUrl && resumeUrl && ( +
+ + + +
+ )} +
+
+

Resume Review

+ {feedback ? ( +
+ + +
+
+ ) : ( + + )} +
+
+
+ ) +} +export default Resume diff --git a/Projects/AI-Resume-Analyser/app/routes/upload.tsx b/Projects/AI-Resume-Analyser/app/routes/upload.tsx new file mode 100644 index 000000000..71f048fdf --- /dev/null +++ b/Projects/AI-Resume-Analyser/app/routes/upload.tsx @@ -0,0 +1,126 @@ +import {type FormEvent, useState} from 'react' +import Navbar from "~/components/Navbar"; +import FileUploader from "~/components/FileUploader"; +import {usePuterStore} from "~/lib/puter"; +import {useNavigate} from "react-router"; +import {convertPdfToImage} from "~/lib/pdf2img"; +import {generateUUID} from "~/lib/utils"; +import {prepareInstructions} from "../../constants"; + +const Upload = () => { + const { auth, isLoading, fs, ai, kv } = usePuterStore(); + const navigate = useNavigate(); + const [isProcessing, setIsProcessing] = useState(false); + const [statusText, setStatusText] = useState(''); + const [file, setFile] = useState(null); + + const handleFileSelect = (file: File | null) => { + setFile(file) + } + + const handleAnalyze = async ({ companyName, jobTitle, jobDescription, file }: { companyName: string, jobTitle: string, jobDescription: string, file: File }) => { + setIsProcessing(true); + + setStatusText('Uploading the file...'); + const uploadedFile = await fs.upload([file]); + if(!uploadedFile) return setStatusText('Error: Failed to upload file'); + + setStatusText('Converting to image...'); + const imageFile = await convertPdfToImage(file); + if(!imageFile.file) return setStatusText('Error: Failed to convert PDF to image'); + + setStatusText('Uploading the image...'); + const uploadedImage = await fs.upload([imageFile.file]); + if(!uploadedImage) return setStatusText('Error: Failed to upload image'); + + setStatusText('Preparing data...'); + const uuid = generateUUID(); + const data = { + id: uuid, + resumePath: uploadedFile.path, + imagePath: uploadedImage.path, + companyName, jobTitle, jobDescription, + feedback: '', + } + await kv.set(`resume:${uuid}`, JSON.stringify(data)); + + setStatusText('Analyzing...'); + + const feedback = await ai.feedback( + uploadedFile.path, + prepareInstructions({ jobTitle, jobDescription }) + ) + if (!feedback) return setStatusText('Error: Failed to analyze resume'); + + const feedbackText = typeof feedback.message.content === 'string' + ? feedback.message.content + : feedback.message.content[0].text; + + data.feedback = JSON.parse(feedbackText); + await kv.set(`resume:${uuid}`, JSON.stringify(data)); + setStatusText('Analysis complete, redirecting...'); + console.log(data); + navigate(`/resume/${uuid}`); + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget.closest('form'); + if(!form) return; + const formData = new FormData(form); + + const companyName = formData.get('company-name') as string; + const jobTitle = formData.get('job-title') as string; + const jobDescription = formData.get('job-description') as string; + + if(!file) return; + + handleAnalyze({ companyName, jobTitle, jobDescription, file }); + } + + return ( +
+ + +
+
+

Smart feedback for your dream job

+ {isProcessing ? ( + <> +

{statusText}

+ + + ) : ( +

Drop your resume for an ATS score and improvement tips

+ )} + {!isProcessing && ( +
+
+ + +
+
+ + +
+
+ +