diff --git a/Dockerfile b/Dockerfile index d5542e07..a36867b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine as builder +FROM node:18-alpine AS builder WORKDIR /app COPY . . RUN npm install --include=dev @@ -11,4 +11,4 @@ COPY --from=builder /app/public ./public COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 20456509..caca000a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ You can learn more about the resume parser algorithm in the ["Resume Parser Algo | **PDF Reader** | [PDF.js](https://github.com/mozilla/pdf.js) | PDF.js reads content from PDF files and is used by the resume parser at its first step to read a resume PDF’s content. | | **PDF Renderer** | [React-pdf](https://github.com/diegomura/react-pdf) | React-pdf creates PDF files and is used by the resume builder to create a downloadable PDF file. | -## 📁 Project Structure +## Project Structure OpenResume is created with the NextJS web framework and follows its project structure. The source code can be found in `src/app`. There are a total of 4 page routes as shown in the table below. (Code path is relative to `src/app`) @@ -56,7 +56,7 @@ OpenResume is created with the NextJS web framework and follows its project stru ### Method 1: npm -1. Download the repo `git clone https://github.com/xitanggg/open-resume.git` +1. Download the repo `git clone https://github.com/ADITYANAIR01/open-resume.git` 2. Change the directory `cd open-resume` 3. Install the dependency `npm install` 4. Start a development server `npm run dev` @@ -64,8 +64,23 @@ OpenResume is created with the NextJS web framework and follows its project stru ### Method 2: Docker -1. Download the repo `git clone https://github.com/xitanggg/open-resume.git` +1. Download the repo `git clone https://github.com/ADITYANAIR01/open-resume.git/` 2. Change the directory `cd open-resume` 3. Build the container `docker build -t open-resume .` -4. Start the container `docker run -p 3000:3000 open-resume` +4. Start the container `docker run --name open-resume -p 3000:3000 open-resume` 5. Open your browser and visit [http://localhost:3000](http://localhost:3000) to see OpenResume live + +## 🔄 Synced Forked Improvements Done + +The following updates were synced from the latest fork improvements: + +- Reorderable contact info section with a GitHub field +- Objective moved below contact info in form and PDF +- Clickable project link field (embedded in project name) +- Comma-separated skills mode with pill UI and left/right reordering +- Comma-separated skills rendered as pill boxes in PDF and live preview +- Skill pill color now matches theme and updates dynamically +- Added 6 new dark theme colors to the theme picker +- Fixed `react-contenteditable` cursor jump bug in bullet textarea +- Fixed `ResumePDFText` import bug in skills PDF + diff --git a/package-lock.json b/package-lock.json index 4cb0ec16..da680eda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,9 @@ "eslint": "8.41.0", "eslint-config-next": "13.4.4", "next": "13.4.4", - "pdfjs": "^2.5.0", "pdfjs-dist": "^3.7.107", "postcss": "8.4.24", "react": "18.2.0", - "react-contenteditable": "^3.3.7", "react-dom": "18.2.0", "react-frame-component": "^5.2.6", "react-redux": "^8.0.7", @@ -1658,28 +1656,6 @@ } } }, - "node_modules/@rkusa/linebreak": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rkusa/linebreak/-/linebreak-1.0.0.tgz", - "integrity": "sha512-yCSm87XA1aYMgfcABSxcIkk3JtCw3AihNceHY+DnZGLvVP/g2z3UWZbi0xIoYpZWAJEVPr5Zt3QE37Q80wF1pA==", - "dependencies": { - "unicode-trie": "^0.3.0" - } - }, - "node_modules/@rkusa/linebreak/node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" - }, - "node_modules/@rkusa/linebreak/node_modules/unicode-trie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-0.3.1.tgz", - "integrity": "sha512-WgVuO0M2jDl7hVfbPgXv2LUrD81HM0bQj/bvLGiw6fJ4Zo8nNFnDrA0/hU2Te/wz6pjxCm5cxJwtLjo2eyV51Q==", - "dependencies": { - "pako": "^0.2.5", - "tiny-inflate": "^1.0.0" - } - }, "node_modules/@rushstack/eslint-patch": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.0.tgz", @@ -2761,9 +2737,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001492", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001492.tgz", - "integrity": "sha512-2efF8SAZwgAX1FJr87KWhvuJxnGJKOnctQa8xLOskAXNXq8oiuqgl6u1kk3fFpsp3GgvzlRjiK1sl63hNtFADw==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "funding": [ { "type": "opencollective", @@ -2777,7 +2753,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/canvas": { "version": "2.11.2", @@ -7091,21 +7068,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/opentype.js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", - "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", - "dependencies": { - "string.prototype.codepointat": "^0.2.1", - "tiny-inflate": "^1.0.3" - }, - "bin": { - "ot": "bin/ot" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -7256,22 +7218,6 @@ "node": ">=8" } }, - "node_modules/pdfjs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pdfjs/-/pdfjs-2.5.0.tgz", - "integrity": "sha512-SGd2QuVp/4WfbRAkb4Jh9+WrB/NULqAhijvvHX3Gkf6vgMLoQeYmLQE54UImWCuiy9/kAM7UMfoDuslP++NRaA==", - "dependencies": { - "@rkusa/linebreak": "^1.0.0", - "opentype.js": "^1.3.3", - "pako": "^2.0.3", - "readable-stream": "^3.6.0", - "unorm": "^1.6.0", - "uuid": "^8.3.1" - }, - "engines": { - "node": ">=7" - } - }, "node_modules/pdfjs-dist": { "version": "3.7.107", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.7.107.tgz", @@ -7284,11 +7230,6 @@ "path2d-polyfill": "^2.0.1" } }, - "node_modules/pdfjs/node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -7674,18 +7615,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - }, - "peerDependencies": { - "react": ">=16.3" - } - }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -7772,6 +7701,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8352,11 +8282,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "devOptional": true }, - "node_modules/string.prototype.codepointat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", - "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" - }, "node_modules/string.prototype.matchall": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", @@ -8895,14 +8820,6 @@ "node": ">= 4.0.0" } }, - "node_modules/unorm": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz", - "integrity": "sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -8971,14 +8888,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -10508,30 +10417,6 @@ "reselect": "^4.1.8" } }, - "@rkusa/linebreak": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rkusa/linebreak/-/linebreak-1.0.0.tgz", - "integrity": "sha512-yCSm87XA1aYMgfcABSxcIkk3JtCw3AihNceHY+DnZGLvVP/g2z3UWZbi0xIoYpZWAJEVPr5Zt3QE37Q80wF1pA==", - "requires": { - "unicode-trie": "^0.3.0" - }, - "dependencies": { - "pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" - }, - "unicode-trie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-0.3.1.tgz", - "integrity": "sha512-WgVuO0M2jDl7hVfbPgXv2LUrD81HM0bQj/bvLGiw6fJ4Zo8nNFnDrA0/hU2Te/wz6pjxCm5cxJwtLjo2eyV51Q==", - "requires": { - "pako": "^0.2.5", - "tiny-inflate": "^1.0.0" - } - } - } - }, "@rushstack/eslint-patch": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.0.tgz", @@ -11354,9 +11239,9 @@ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" }, "caniuse-lite": { - "version": "1.0.30001492", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001492.tgz", - "integrity": "sha512-2efF8SAZwgAX1FJr87KWhvuJxnGJKOnctQa8xLOskAXNXq8oiuqgl6u1kk3fFpsp3GgvzlRjiK1sl63hNtFADw==" + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==" }, "canvas": { "version": "2.11.2", @@ -14510,15 +14395,6 @@ "is-wsl": "^2.2.0" } }, - "opentype.js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", - "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", - "requires": { - "string.prototype.codepointat": "^0.2.1", - "tiny-inflate": "^1.0.3" - } - }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -14624,26 +14500,6 @@ "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", "optional": true }, - "pdfjs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pdfjs/-/pdfjs-2.5.0.tgz", - "integrity": "sha512-SGd2QuVp/4WfbRAkb4Jh9+WrB/NULqAhijvvHX3Gkf6vgMLoQeYmLQE54UImWCuiy9/kAM7UMfoDuslP++NRaA==", - "requires": { - "@rkusa/linebreak": "^1.0.0", - "opentype.js": "^1.3.3", - "pako": "^2.0.3", - "readable-stream": "^3.6.0", - "unorm": "^1.6.0", - "uuid": "^8.3.1" - }, - "dependencies": { - "pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" - } - } - }, "pdfjs-dist": { "version": "3.7.107", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.7.107.tgz", @@ -14888,15 +14744,6 @@ "loose-envify": "^1.1.0" } }, - "react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "requires": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - } - }, "react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -14949,6 +14796,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "optional": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -15360,11 +15208,6 @@ } } }, - "string.prototype.codepointat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", - "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" - }, "string.prototype.matchall": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", @@ -15758,11 +15601,6 @@ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "dev": true }, - "unorm": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz", - "integrity": "sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==" - }, "untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -15806,11 +15644,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", diff --git a/package.json b/package.json index 49258b93..a39a89b4 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,9 @@ "eslint": "8.41.0", "eslint-config-next": "13.4.4", "next": "13.4.4", - "pdfjs": "^2.5.0", "pdfjs-dist": "^3.7.107", "postcss": "8.4.24", "react": "18.2.0", - "react-contenteditable": "^3.3.7", "react-dom": "18.2.0", "react-frame-component": "^5.2.6", "react-redux": "^8.0.7", diff --git a/src/app/components/Resume/ResumeControlBar.tsx b/src/app/components/Resume/ResumeControlBar.tsx index ec812005..291d8b47 100644 --- a/src/app/components/Resume/ResumeControlBar.tsx +++ b/src/app/components/Resume/ResumeControlBar.tsx @@ -27,10 +27,17 @@ const ResumeControlBar = ({ }); const [instance, update] = usePDF({ document }); + const isDownloadReady = Boolean(instance.url) && !instance.loading && !instance.error; // Hook to update pdf when document changes useEffect(() => { - update(); + const timeoutId = window.setTimeout(() => { + update(); + }, 150); + + return () => { + window.clearTimeout(timeoutId); + }; }, [update, document]); return ( @@ -60,13 +67,30 @@ const ResumeControlBar = ({ { + if (!isDownloadReady) { + event.preventDefault(); + } + }} > - Download Resume + + {instance.loading ? "Preparing PDF..." : "Download Resume"} + + {instance.error && ( +

+ Unable to generate PDF right now. +

+ )} ); }; diff --git a/src/app/components/Resume/ResumePDF/ResumePDFCustom.tsx b/src/app/components/Resume/ResumePDF/ResumePDFCustom.tsx index fe8c2303..e58b2e49 100644 --- a/src/app/components/Resume/ResumePDF/ResumePDFCustom.tsx +++ b/src/app/components/Resume/ResumePDF/ResumePDFCustom.tsx @@ -2,8 +2,9 @@ import { View } from "@react-pdf/renderer"; import { ResumePDFSection, ResumePDFBulletList, + ResumePDFText, } from "components/Resume/ResumePDF/common"; -import { styles } from "components/Resume/ResumePDF/styles"; +import { styles, spacing } from "components/Resume/ResumePDF/styles"; import type { ResumeCustom } from "lib/redux/types"; export const ResumePDFCustom = ({ @@ -11,21 +12,50 @@ export const ResumePDFCustom = ({ custom, themeColor, showBulletPoints, + customDisplayMode = "bullet", }: { heading: string; custom: ResumeCustom; themeColor: string; showBulletPoints: boolean; + customDisplayMode?: "bullet" | "comma"; }) => { const { descriptions } = custom; return ( - - + + {customDisplayMode === "comma" ? ( + descriptions.map((desc, idx) => ( + + + {desc} + + + )) + ) : ( + + )} ); diff --git a/src/app/components/Resume/ResumePDF/ResumePDFProfile.tsx b/src/app/components/Resume/ResumePDF/ResumePDFProfile.tsx index a01f5225..feb6f7cb 100644 --- a/src/app/components/Resume/ResumePDF/ResumePDFProfile.tsx +++ b/src/app/components/Resume/ResumePDF/ResumePDFProfile.tsx @@ -11,17 +11,32 @@ import { } from "components/Resume/ResumePDF/common"; import type { ResumeProfile } from "lib/redux/types"; +const DEFAULT_CONTACT_ORDER = ["email", "phone", "url", "github", "location"]; + export const ResumePDFProfile = ({ profile, themeColor, isPDF, + contactOrder, }: { profile: ResumeProfile; themeColor: string; isPDF: boolean; + contactOrder?: string[]; }) => { - const { name, email, phone, url, summary, location } = profile; - const iconProps = { email, phone, location, url }; + const { name, email, phone, url, summary, location, github } = profile; + const iconPropsMap: Record = { + email, + phone, + location, + url, + github, + }; + + const orderedFields = + contactOrder && contactOrder.length > 0 + ? contactOrder + : DEFAULT_CONTACT_ORDER; return ( @@ -32,7 +47,6 @@ export const ResumePDFProfile = ({ > {name} - {summary && {summary}} - {Object.entries(iconProps).map(([key, value]) => { + {orderedFields.map((key) => { + const value = iconPropsMap[key]; if (!value) return null; - let iconType = key as IconType; - if (key === "url") { + let iconType: IconType = "url"; + if (key === "email") { + iconType = "email"; + } else if (key === "phone") { + iconType = "phone"; + } else if (key === "location") { + iconType = "location"; + } else if (key === "github") { + iconType = "url_github"; + } else if (key === "url") { if (value.includes("github")) { iconType = "url_github"; } else if (value.includes("linkedin")) { iconType = "url_linkedin"; + } else { + iconType = "url"; } } - const shouldUseLinkWrapper = ["email", "url", "phone"].includes(key); + const shouldUseLinkWrapper = ["email", "url", "phone", "github"].includes(key); const Wrapper = ({ children }: { children: React.ReactNode }) => { if (!shouldUseLinkWrapper) return <>{children}; @@ -63,7 +88,7 @@ export const ResumePDFProfile = ({ break; } case "phone": { - src = `tel:${value.replace(/[^\d+]/g, "")}`; // Keep only + and digits + src = `tel:${value.replace(/[^\d+]/g, "")}`; break; } default: { @@ -95,6 +120,11 @@ export const ResumePDFProfile = ({ ); })} + {summary && ( + + {summary} + + )} ); }; diff --git a/src/app/components/Resume/ResumePDF/ResumePDFProject.tsx b/src/app/components/Resume/ResumePDF/ResumePDFProject.tsx index b7d8f46a..a010b045 100644 --- a/src/app/components/Resume/ResumePDF/ResumePDFProject.tsx +++ b/src/app/components/Resume/ResumePDF/ResumePDFProject.tsx @@ -3,6 +3,7 @@ import { ResumePDFSection, ResumePDFBulletList, ResumePDFText, + ResumePDFLink, } from "components/Resume/ResumePDF/common"; import { styles, spacing } from "components/Resume/ResumePDF/styles"; import type { ResumeProject } from "lib/redux/types"; @@ -11,14 +12,16 @@ export const ResumePDFProject = ({ heading, projects, themeColor, + isPDF, }: { heading: string; projects: ResumeProject[]; themeColor: string; + isPDF: boolean; }) => { return ( - {projects.map(({ project, date, descriptions }, idx) => ( + {projects.map(({ project, date, descriptions, url }, idx) => ( - {project} + {url ? ( + + {project} + + ) : ( + {project} + )} {date} diff --git a/src/app/components/Resume/ResumePDF/ResumePDFSkills.tsx b/src/app/components/Resume/ResumePDF/ResumePDFSkills.tsx index 6441afa8..071348a4 100644 --- a/src/app/components/Resume/ResumePDF/ResumePDFSkills.tsx +++ b/src/app/components/Resume/ResumePDF/ResumePDFSkills.tsx @@ -3,6 +3,7 @@ import { ResumePDFSection, ResumePDFBulletList, ResumeFeaturedSkill, + ResumePDFText, } from "components/Resume/ResumePDF/common"; import { styles, spacing } from "components/Resume/ResumePDF/styles"; import type { ResumeSkills } from "lib/redux/types"; @@ -12,11 +13,13 @@ export const ResumePDFSkills = ({ skills, themeColor, showBulletPoints, + skillsDisplayMode = "bullet", }: { heading: string; skills: ResumeSkills; themeColor: string; showBulletPoints: boolean; + skillsDisplayMode?: "bullet" | "comma"; }) => { const { descriptions, featuredSkills } = skills; const featuredSkillsWithText = featuredSkills.filter((item) => item.skill); @@ -55,11 +58,38 @@ export const ResumePDFSkills = ({ ))} )} - - + + {skillsDisplayMode === "comma" ? ( + descriptions.map((skill, idx) => ( + + + {skill} + + + )) + ) : ( + + )} ); diff --git a/src/app/components/Resume/ResumePDF/index.tsx b/src/app/components/Resume/ResumePDF/index.tsx index b1ad01d7..6a146013 100644 --- a/src/app/components/Resume/ResumePDF/index.tsx +++ b/src/app/components/Resume/ResumePDF/index.tsx @@ -72,6 +72,7 @@ export const ResumePDF = ({ heading={formToHeading["projects"]} projects={projects} themeColor={themeColor} + isPDF={isPDF} /> ), skills: () => ( @@ -80,6 +81,7 @@ export const ResumePDF = ({ skills={skills} themeColor={themeColor} showBulletPoints={showBulletPoints["skills"]} + skillsDisplayMode={settings.skillsDisplayMode ?? "bullet"} /> ), custom: () => ( @@ -88,6 +90,7 @@ export const ResumePDF = ({ custom={custom} themeColor={themeColor} showBulletPoints={showBulletPoints["custom"]} + customDisplayMode={settings.customDisplayMode ?? "bullet"} /> ), }; @@ -123,6 +126,7 @@ export const ResumePDF = ({ profile={profile} themeColor={themeColor} isPDF={isPDF} + contactOrder={settings.contactOrder} /> {showFormsOrder.map((form) => { const Component = formTypeToComponent[form]; diff --git a/src/app/components/Resume/index.tsx b/src/app/components/Resume/index.tsx index 7a0b8da0..7c14c508 100644 --- a/src/app/components/Resume/index.tsx +++ b/src/app/components/Resume/index.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useMemo } from "react"; +import { useDeferredValue, useState, useMemo } from "react"; import { ResumeIframeCSR } from "components/Resume/ResumeIFrame"; import { ResumePDF } from "components/Resume/ResumePDF"; import { @@ -21,8 +21,21 @@ export const Resume = () => { const [scale, setScale] = useState(0.8); const resume = useAppSelector(selectResume); const settings = useAppSelector(selectSettings); - const document = useMemo( - () => , + const deferredResume = useDeferredValue(resume); + const deferredSettings = useDeferredValue(settings); + + const exportDocument = useMemo( + () => ( + + ), + [deferredResume, deferredSettings] + ); + const previewDocument = useMemo( + () => , [resume, settings] ); @@ -41,19 +54,16 @@ export const Resume = () => { scale={scale} enablePDFViewer={DEBUG_RESUME_PDF_FLAG} > - + {previewDocument} diff --git a/src/app/components/ResumeDropzone.tsx b/src/app/components/ResumeDropzone.tsx index 2b984855..da18f4f3 100644 --- a/src/app/components/ResumeDropzone.tsx +++ b/src/app/components/ResumeDropzone.tsx @@ -1,17 +1,12 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { LockClosedIcon } from "@heroicons/react/24/solid"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { parseResumeFromPdf } from "lib/parse-resume-from-pdf"; -import { - getHasUsedAppBefore, - saveStateToLocalStorage, -} from "lib/redux/local-storage"; -import { type ShowForm, initialSettings } from "lib/redux/settingsSlice"; +import { saveParsedResumeToBuilderState } from "lib/redux/parsed-resume-to-builder-state"; import { useRouter } from "next/navigation"; import addPdfSrc from "public/assets/add-pdf.svg"; import Image from "next/image"; import { cx } from "lib/cx"; -import { deepClone } from "lib/deep-clone"; const defaultFileState = { name: "", @@ -31,10 +26,20 @@ export const ResumeDropzone = ({ const [file, setFile] = useState(defaultFileState); const [isHoveredOnDropzone, setIsHoveredOnDropzone] = useState(false); const [hasNonPdfFile, setHasNonPdfFile] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [isImporting, setIsImporting] = useState(false); const router = useRouter(); const hasFile = Boolean(file.name); + useEffect(() => { + return () => { + if (file.fileUrl) { + URL.revokeObjectURL(file.fileUrl); + } + }; + }, [file.fileUrl]); + const setNewFile = (newFile: File) => { if (file.fileUrl) { URL.revokeObjectURL(file.fileUrl); @@ -49,11 +54,18 @@ export const ResumeDropzone = ({ const onDrop = (event: React.DragEvent) => { event.preventDefault(); const newFile = event.dataTransfer.files[0]; - if (newFile.name.endsWith(".pdf")) { + if (!newFile) { + setIsHoveredOnDropzone(false); + return; + } + + if (isPdfFile(newFile)) { setHasNonPdfFile(false); + setErrorMessage(""); setNewFile(newFile); } else { setHasNonPdfFile(true); + setErrorMessage("Only PDF file is supported."); } setIsHoveredOnDropzone(false); }; @@ -63,35 +75,46 @@ export const ResumeDropzone = ({ if (!files) return; const newFile = files[0]; - setNewFile(newFile); + if (!newFile) return; + + if (isPdfFile(newFile)) { + setHasNonPdfFile(false); + setErrorMessage(""); + setNewFile(newFile); + } else { + setHasNonPdfFile(true); + setErrorMessage("Only PDF file is supported."); + } }; const onRemove = () => { + if (file.fileUrl) { + URL.revokeObjectURL(file.fileUrl); + } setFile(defaultFileState); + setErrorMessage(""); + setHasNonPdfFile(false); onFileUrlChange(""); }; const onImportClick = async () => { - const resume = await parseResumeFromPdf(file.fileUrl); - const settings = deepClone(initialSettings); - - // Set formToShow settings based on uploaded resume if users have used the app before - if (getHasUsedAppBefore()) { - const sections = Object.keys(settings.formToShow) as ShowForm[]; - const sectionToFormToShow: Record = { - workExperiences: resume.workExperiences.length > 0, - educations: resume.educations.length > 0, - projects: resume.projects.length > 0, - skills: resume.skills.descriptions.length > 0, - custom: resume.custom.descriptions.length > 0, - }; - for (const section of sections) { - settings.formToShow[section] = sectionToFormToShow[section]; - } - } + if (isImporting || !file.fileUrl) return; - saveStateToLocalStorage({ resume, settings }); - router.push("/resume-builder"); + setErrorMessage(""); + setIsImporting(true); + try { + const parsedResume = await parseResumeFromPdf(file.fileUrl); + saveParsedResumeToBuilderState(parsedResume, { enableSmartLayout: true }); + router.push("/resume-builder"); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to parse resume. Please try another PDF file."; + setErrorMessage(message); + } finally { + setIsImporting(false); + } }; return ( @@ -148,6 +171,7 @@ export const ResumeDropzone = ({ type="button" className="outline-theme-blue rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500" title="Remove file" + disabled={isImporting} onClick={onRemove} > @@ -181,17 +205,27 @@ export const ResumeDropzone = ({ )} + {isImporting && !playgroundView && ( +

+ Parsing and preparing your resume. This can take a few seconds. +

+ )}

Note: {!playgroundView ? "Import" : "Parser"} works best on single column resume

)} + {errorMessage && ( +

{errorMessage}

+ )} @@ -207,3 +241,6 @@ const getFileSizeString = (fileSizeB: number) => { return fileSizeMB.toPrecision(3) + " MB"; } }; + +const isPdfFile = (file: File) => + file.type === "application/pdf" || /\.pdf$/i.test(file.name); diff --git a/src/app/components/ResumeForm/CustomForm.tsx b/src/app/components/ResumeForm/CustomForm.tsx index 4597dfd0..5f84412c 100644 --- a/src/app/components/ResumeForm/CustomForm.tsx +++ b/src/app/components/ResumeForm/CustomForm.tsx @@ -1,11 +1,14 @@ import { Form } from "components/ResumeForm/Form"; import { BulletListIconButton } from "components/ResumeForm/Form/IconButton"; -import { BulletListTextarea } from "components/ResumeForm/Form/InputGroup"; +import { BulletListTextarea, InputGroupWrapper } from "components/ResumeForm/Form/InputGroup"; +import { CommaInput } from "components/ResumeForm/Form/CommaInput"; import { useAppDispatch, useAppSelector } from "lib/redux/hooks"; import { changeCustom, selectCustom } from "lib/redux/resumeSlice"; import { selectShowBulletPoints, changeShowBulletPoints, + selectCustomDisplayMode, + changeCustomDisplayMode, } from "lib/redux/settingsSlice"; export const CustomForm = () => { @@ -14,6 +17,7 @@ export const CustomForm = () => { const { descriptions } = custom; const form = "custom"; const showBulletPoints = useAppSelector(selectShowBulletPoints(form)); + const customDisplayMode = useAppSelector(selectCustomDisplayMode) ?? "bullet"; const handleCustomChange = (field: "descriptions", value: string[]) => { dispatch(changeCustom({ field, value })); @@ -26,23 +30,68 @@ export const CustomForm = () => { return (
-
- -
- + + Display mode: + +
+ + +
+
+ + {customDisplayMode === "bullet" ? ( +
+ +
+ +
-
+ ) : ( +
+ + handleCustomChange("descriptions", value)} + /> + +

+ Items will appear inline separated by commas on the resume. +

+
+ )}
); diff --git a/src/app/components/ResumeForm/Form/CommaInput.tsx b/src/app/components/ResumeForm/Form/CommaInput.tsx new file mode 100644 index 00000000..88598dce --- /dev/null +++ b/src/app/components/ResumeForm/Form/CommaInput.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { ChevronLeftIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline"; + +export const CommaInput = ({ + items, + onChange, + placeholder, +}: { + items: string[]; + onChange: (value: string[]) => void; + placeholder?: string; +}) => { + const [inputValue, setInputValue] = useState(""); + + const addItems = (raw: string) => { + const newItems = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (newItems.length > 0) { + onChange([...items, ...newItems]); + setInputValue(""); + } + }; + + const deleteItem = (idx: number) => { + onChange(items.filter((_, i) => i !== idx)); + }; + + const moveItem = (idx: number, direction: "left" | "right") => { + const newItems = [...items]; + const swapIdx = direction === "left" ? idx - 1 : idx + 1; + const temp = newItems[idx]; + newItems[idx] = newItems[swapIdx]; + newItems[swapIdx] = temp; + onChange(newItems); + }; + + return ( +
+ {items.map((item, idx) => ( + + + {item} + + + + ))} + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addItems(inputValue); + } + if (e.key === "Backspace" && inputValue === "" && items.length > 0) { + deleteItem(items.length - 1); + } + }} + onBlur={() => { + if (inputValue.trim()) addItems(inputValue); + }} + /> +
+ ); +}; diff --git a/src/app/components/ResumeForm/Form/InputGroup.tsx b/src/app/components/ResumeForm/Form/InputGroup.tsx index 8f73b995..9e7db60e 100644 --- a/src/app/components/ResumeForm/Form/InputGroup.tsx +++ b/src/app/components/ResumeForm/Form/InputGroup.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import ContentEditable from "react-contenteditable"; +import { useState, useEffect, useRef } from "react"; import { useAutosizeTextareaHeight } from "lib/hooks/useAutosizeTextareaHeight"; interface InputProps { @@ -105,12 +104,9 @@ export const BulletListTextarea = ( /** * BulletListTextareaGeneral is a textarea where each new line starts with a bullet point. * - * In its core, it uses a div with contentEditable set to True. However, when - * contentEditable is True, user can paste in any arbitrary html and it would - * render. So to make it behaves like a textarea, it strips down all html while - * keeping only the text part. - * - * Reference: https://stackoverflow.com/a/74998090/7699841 + * Uses a native contentEditable div controlled via a ref so that the DOM is only + * updated when the value changes from outside (e.g. loaded from localStorage) and + * NOT on every user keystroke. This prevents the cursor from jumping on backspace. */ const BulletListTextareaGeneral = ({ label, @@ -121,25 +117,51 @@ const BulletListTextareaGeneral = ({ onChange, showBulletPoints = true, }: InputProps & { showBulletPoints?: boolean }) => { - const html = getHTMLFromBulletListStrings(bulletListStrings); + const divRef = useRef(null); + // Tracks the stringified value we last wrote into the DOM so we can distinguish + // user edits (no DOM update needed) from external changes (DOM must be refreshed). + const lastWrittenJsonRef = useRef(null); + + // Set initial HTML content on mount only. + useEffect(() => { + if (divRef.current) { + divRef.current.innerHTML = getHTMLFromBulletListStrings(bulletListStrings); + lastWrittenJsonRef.current = JSON.stringify(bulletListStrings); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync when the value changes externally (e.g. import / localStorage load), + // but skip the update when the change originated from the user typing. + useEffect(() => { + const incoming = JSON.stringify(bulletListStrings); + if (incoming !== lastWrittenJsonRef.current && divRef.current) { + divRef.current.innerHTML = getHTMLFromBulletListStrings(bulletListStrings); + lastWrittenJsonRef.current = incoming; + } + }, [bulletListStrings]); + + const handleInput = () => { + if (!divRef.current) return; + const newStrings = getBulletListStringsFromInnerText( + divRef.current.innerText + ); + // Record what we're about to propagate so the effect above won't reset the DOM. + lastWrittenJsonRef.current = JSON.stringify(newStrings); + onChange(name, newStrings); + }; + return ( - div]:list-item ${ showBulletPoints ? "pl-7" : "[&>div]:list-['']" }`} - // Note: placeholder currently doesn't work - placeholder={placeholder} - onChange={(e) => { - if (e.type === "input") { - const { innerText } = e.currentTarget as HTMLDivElement; - const newBulletListStrings = - getBulletListStringsFromInnerText(innerText); - onChange(name, newBulletListStrings); - } - }} - html={html} + onInput={handleInput} /> ); @@ -173,13 +195,21 @@ const getBulletListStringsFromInnerText = (innerText: string) => { return getStringsByLineBreak(newInnerText); }; +const escapeHtml = (text: string) => + text + .replace(/&/g, "&") + .replace(//g, ">"); + const getHTMLFromBulletListStrings = (bulletListStrings: string[]) => { // If bulletListStrings is an empty array, make it an empty div if (bulletListStrings.length === 0) { return "
"; } - return bulletListStrings.map((text) => `
${text}
`).join(""); + return bulletListStrings + .map((text) => `
${escapeHtml(text)}
`) + .join(""); }; /** diff --git a/src/app/components/ResumeForm/ProfileForm.tsx b/src/app/components/ResumeForm/ProfileForm.tsx index c7461d87..e64a7443 100644 --- a/src/app/components/ResumeForm/ProfileForm.tsx +++ b/src/app/components/ResumeForm/ProfileForm.tsx @@ -2,12 +2,35 @@ import { BaseForm } from "components/ResumeForm/Form"; import { Input, Textarea } from "components/ResumeForm/Form/InputGroup"; import { useAppDispatch, useAppSelector } from "lib/redux/hooks"; import { changeProfile, selectProfile } from "lib/redux/resumeSlice"; +import { + changeContactOrder, + selectContactOrder, +} from "lib/redux/settingsSlice"; import { ResumeProfile } from "lib/redux/types"; +import { ArrowSmallUpIcon, ArrowSmallDownIcon } from "@heroicons/react/24/outline"; + +const CONTACT_FIELD_CONFIG: Record< + string, + { label: string; placeholder: string } +> = { + email: { label: "Email", placeholder: "hello@khanacademy.org" }, + phone: { label: "Phone", placeholder: "(123)456-7890" }, + url: { label: "Website", placeholder: "linkedin.com/in/khanacademy" }, + github: { label: "GitHub", placeholder: "github.com/username" }, + location: { label: "Location", placeholder: "NYC, NY" }, +}; export const ProfileForm = () => { const profile = useAppSelector(selectProfile); + const contactOrder = useAppSelector(selectContactOrder) ?? [ + "email", + "phone", + "url", + "github", + "location", + ]; const dispatch = useAppDispatch(); - const { name, email, phone, url, summary, location } = profile; + const { name, summary } = profile; const handleProfileChange = (field: keyof ResumeProfile, value: string) => { dispatch(changeProfile({ field, value })); @@ -24,6 +47,56 @@ export const ProfileForm = () => { value={name} onChange={handleProfileChange} /> + {/* Contact fields — reorderable with up/down arrows */} +
+

+ Contact Info{" "} + (drag ↕ to reorder) +

+ {contactOrder.map((field, idx) => { + const config = CONTACT_FIELD_CONFIG[field]; + if (!config) return null; + const isFirst = idx === 0; + const isLast = idx === contactOrder.length - 1; + return ( +
+
+ + +
+ +
+ ); + })} +
+