init commit
This commit is contained in:
35
frontend/resume-builder/src/App.jsx
Normal file
35
frontend/resume-builder/src/App.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import Dashboard from "./pages/Home/Dashboard";
|
||||
import EditResume from "./pages/ResumeUpdate/EditResume";
|
||||
import UserProvider from "./context/userContext";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<UserProvider>
|
||||
<div>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Default Route */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/resume/:resumeId" element={<EditResume />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</div>
|
||||
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
className: "",
|
||||
style: {
|
||||
fontSize: "13px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</UserProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
frontend/resume-builder/src/assets/hero-img.png
Normal file
BIN
frontend/resume-builder/src/assets/hero-img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
1
frontend/resume-builder/src/assets/react.svg
Normal file
1
frontend/resume-builder/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/resume-builder/src/assets/template-one.png
Normal file
BIN
frontend/resume-builder/src/assets/template-one.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
frontend/resume-builder/src/assets/template-three.png
Normal file
BIN
frontend/resume-builder/src/assets/template-three.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
BIN
frontend/resume-builder/src/assets/template-two.png
Normal file
BIN
frontend/resume-builder/src/assets/template-two.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
@ -0,0 +1,39 @@
|
||||
import React, { useContext } from "react";
|
||||
import { UserContext } from "../../context/userContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const ProfileInfoCard = () => {
|
||||
const { user, clearUser } = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handelLogout = () => {
|
||||
localStorage.clear();
|
||||
clearUser();
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
user && (
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={user.profileImageUrl}
|
||||
alt=""
|
||||
className="w-11 h-11 bg-gray-300 rounded-full mr-3"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold leading-3">
|
||||
{user.name || ""}
|
||||
</div>
|
||||
<button
|
||||
className="text-purple-500 text-sm font-semibold cursor-pointer hover:underline"
|
||||
onClick={handelLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileInfoCard;
|
||||
@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getLightColorFromImage } from "../../utils/helper";
|
||||
|
||||
const ResumeSummaryCard = ({ imgUrl, title, lastUpdated, onSelect }) => {
|
||||
|
||||
const [bgColor, setBgColor] = useState("#ffffff");
|
||||
|
||||
useEffect(() => {
|
||||
if (imgUrl) {
|
||||
getLightColorFromImage(imgUrl)
|
||||
.then((color) => {
|
||||
setBgColor(color);
|
||||
})
|
||||
.catch(() => {
|
||||
setBgColor("#ffffff");
|
||||
});
|
||||
}
|
||||
}, [imgUrl]);
|
||||
|
||||
return <div
|
||||
className="h-[300px] flex flex-col items-center justify-between bg-white rounded-lg border border-gray-200 hover:border-purple-300 overflow-hidden cursor-pointer"
|
||||
style={{backgroundColor: bgColor}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="p-4">
|
||||
{imgUrl ? (
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt=""
|
||||
className="w-[100%] h-[200px] rounded"
|
||||
/>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-white px-4 py-3">
|
||||
<h5 className="text-sm font-medium truncate overflow-hidden whitespace-nowrap">{title}</h5>
|
||||
<p className="text-xs font-medium text-gray-500 mt-0.5">
|
||||
Last Updated: {lastUpdated}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default ResumeSummaryCard;
|
||||
@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
const TemplateCard = ({ thumbnailImg, isSelected, onSelect }) => {
|
||||
return <div
|
||||
className={`h-auto md:h-[300px] flex flex-col items-center justify-between bg-white rounded-lg border border-gray-200 hover:border-purple-300 overflow-hidden cursor-pointer
|
||||
${isSelected ? "border-purple-500 border-2" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{thumbnailImg ? (
|
||||
<img src={thumbnailImg} alt="" className="w-[100%] rounded" />
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
};
|
||||
|
||||
export default TemplateCard;
|
||||
46
frontend/resume-builder/src/components/Inputs/Input.jsx
Normal file
46
frontend/resume-builder/src/components/Inputs/Input.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { useState } from "react";
|
||||
import { FaRegEye, FaRegEyeSlash } from "react-icons/fa6";
|
||||
|
||||
const Input = ({ value, onChange, label, placeholder, type }) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const toggleShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
return <div>
|
||||
<label className="text-[13px] text-slate-800">{label}</label>
|
||||
|
||||
<div className="input-box">
|
||||
<input
|
||||
type={
|
||||
type == "password" ? (showPassword ? "text" : "password") : type
|
||||
}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent outline-none"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e)}
|
||||
/>
|
||||
|
||||
{type === "password" && (
|
||||
<>
|
||||
{showPassword ? (
|
||||
<FaRegEye
|
||||
size={22}
|
||||
className="text-primary cursor-pointer"
|
||||
onClick={() => toggleShowPassword()}
|
||||
/>
|
||||
) : (
|
||||
<FaRegEyeSlash
|
||||
size={22}
|
||||
className="text-slate-400 cursor-pointer"
|
||||
onClick={() => toggleShowPassword()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default Input;
|
||||
@ -0,0 +1,77 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { LuUser, LuUpload, LuTrash } from "react-icons/lu";
|
||||
|
||||
const ProfilePhotoSelector = ({image, setImage, preview, setPreview}) => {
|
||||
const inputRef = useRef(null);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
|
||||
const handleImageChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// Update the image state
|
||||
setImage(file);
|
||||
|
||||
// Generate preview URL from the file
|
||||
const preview = URL.createObjectURL(file);
|
||||
if(setPreview){
|
||||
setPreview(preview)
|
||||
}
|
||||
setPreviewUrl(preview);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImage(null);
|
||||
setPreviewUrl(null);
|
||||
|
||||
if(setPreview){
|
||||
setPreview(null)
|
||||
}
|
||||
};
|
||||
|
||||
const onChooseFile = () => {
|
||||
inputRef.current.click();
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-center mb-6">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={inputRef}
|
||||
onChange={handleImageChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{!image ? (
|
||||
<div className="w-20 h-20 flex items-center justify-center bg-purple-50 rounded-full relative cursor-pointer">
|
||||
<LuUser className="text-4xl text-purple-500" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 flex items-center justify-center bg-linear-to-r from-purple-500/85 to-purple-700 text-white rounded-full absolute -bottom-1 -right-1 cursor-pointer"
|
||||
onClick={onChooseFile}
|
||||
>
|
||||
<LuUpload />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={preview || previewUrl}
|
||||
alt="profile photo"
|
||||
className="w-20 h-20 rounded-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 flex items-center justify-center bg-red-500 text-white rounded-full absolute -bottom-1 -right-1 cursor-pointer"
|
||||
onClick={handleRemoveImage}
|
||||
>
|
||||
<LuTrash />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfilePhotoSelector
|
||||
39
frontend/resume-builder/src/components/Inputs/TitleInput.jsx
Normal file
39
frontend/resume-builder/src/components/Inputs/TitleInput.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React, { useState } from "react";
|
||||
import { LuCheck, LuPencil } from "react-icons/lu";
|
||||
|
||||
const TitleInput = ({ title, setTitle }) => {
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
|
||||
return <div className="flex items-center gap-3">
|
||||
{showInput ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Resume title"
|
||||
className="text-sm md:text-[17px] bg-transparent outline-none text-black font-semibold border-b border-gray-300 pb-1"
|
||||
value={title}
|
||||
onChange={({ target }) => setTitle(target.value)}
|
||||
/>
|
||||
|
||||
<button className="cursor-pointer">
|
||||
<LuCheck
|
||||
className="text-[16px] text-purple-600"
|
||||
onClick={() => setShowInput((prevState) => !prevState)}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-sm md:text-[17px] font-semibold">{title}</h2>
|
||||
<button className="cursor-pointer">
|
||||
<LuPencil
|
||||
className="text-sm text-purple-600"
|
||||
onClick={() => setShowInput((prevState) => !prevState)}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
};
|
||||
|
||||
export default TitleInput;
|
||||
71
frontend/resume-builder/src/components/Modal.jsx
Normal file
71
frontend/resume-builder/src/components/Modal.jsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
hideHeader,
|
||||
showActionBtn,
|
||||
actionBtnIcon = null,
|
||||
actionBtnText,
|
||||
onActionClick,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-center items-center w-full h-full bg-black/40">
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={`relative flex flex-col bg-white shadow-lg rounded-lg overflow-hidden
|
||||
`}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h3 className="md:text-lg font-medium text-gray-900">{title}</h3>
|
||||
|
||||
{showActionBtn && (
|
||||
<button
|
||||
className="btn-small-light mr-12"
|
||||
onClick={() => onActionClick()}
|
||||
>
|
||||
{actionBtnIcon}
|
||||
{actionBtnText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 flex justify-center items-center absolute top-3.5 right-3.5"
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M1 1l6 6m0 0l6 6M7 7l6-6M7 7l-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Modal Body (Scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
24
frontend/resume-builder/src/components/Progress.jsx
Normal file
24
frontend/resume-builder/src/components/Progress.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
const Progress = ({ progress = 0, total = 5, color, bgColor }) => {
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{[...Array(total)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded transition-all ${
|
||||
index < progress ? "bg-cyan-500 " : "bg-cyan-100"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
index < progress
|
||||
? color || "rgba(1,1,1,1)"
|
||||
: bgColor || "rgba(1,1,1,0.1)",
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Progress;
|
||||
@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
const ActionLink = ({icon, link, bgColor}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-[25px] h-[25px] flex items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] font-medium underline cursor-pointer break-all">{link}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLink
|
||||
@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
const CertificationInfo = ({ title, issuer, year, bgColor }) => {
|
||||
return <div className="">
|
||||
<h3 className={`text-[15px] font-semibold text-gray-900`}>{title}</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{year && (
|
||||
<div
|
||||
className="text-[11px] font-bold text-gray-800 px-3 py-0.5 inline-block mt-2 rounded-lg"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{year}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[12px] text-gray-700 font-medium mt-1">{issuer}</p>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default CertificationInfo;
|
||||
@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
const ContactInfo = ({ icon, iconBG, value }) => {
|
||||
return <div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-[30px] h-[30px] flex items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: iconBG }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<p className="flex-1 text-[12px] font-medium break-all">{value}</p>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default ContactInfo;
|
||||
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
const EducationInfo = ({ degree, institution, duration }) => {
|
||||
return <div className="mb-5">
|
||||
<h3 className={`text-[15px] font-semibold text-gray-900`}>{degree}</h3>
|
||||
<p className="text-sm text-gray-700 font-medium">{institution}</p>
|
||||
<p className="text-xs text-gray-500 font-medium italic mt-0.5">
|
||||
{duration}
|
||||
</p>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default EducationInfo;
|
||||
@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import Progress from "../Progress";
|
||||
|
||||
const LanguageInfo = ({ language, progress, accentColor, bgColor }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={`text-[12px] font-semibold text-gray-900`}>{language}</p>
|
||||
{progress > 0 && (
|
||||
<Progress
|
||||
progress={(progress / 100) * 5}
|
||||
color={accentColor}
|
||||
bgColor={bgColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LanguageSection = ({ languages, accentColor, bgColor }) => {
|
||||
return <div className="flex flex-col gap-2">
|
||||
{languages?.map((language, index) => (
|
||||
<LanguageInfo
|
||||
key={`slanguage_${index}`}
|
||||
language={language.name}
|
||||
progress={language.progress}
|
||||
accentColor={accentColor}
|
||||
bgColor={bgColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
};
|
||||
|
||||
export default LanguageSection;
|
||||
@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { LuGithub, LuExternalLink } from "react-icons/lu";
|
||||
import ActionLink from "./ActionLink";
|
||||
|
||||
const ProjectInfo = ({
|
||||
title,
|
||||
description,
|
||||
githubLink,
|
||||
liveDemoUrl,
|
||||
bgColor,
|
||||
isPreview
|
||||
}) => {
|
||||
return <div className="mb-5">
|
||||
<h3
|
||||
className={`${
|
||||
isPreview ? "text-xs" : "text-base"
|
||||
} font-semibold text-gray-900`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-700 font-medium mt-1">{description}</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{githubLink && <ActionLink icon={<LuGithub />} link={githubLink} bgColor={bgColor} />}
|
||||
|
||||
{liveDemoUrl && <ActionLink icon={<LuExternalLink />} link={liveDemoUrl} bgColor={bgColor} />}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default ProjectInfo;
|
||||
@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
const RatingInput = ({
|
||||
value = 0,
|
||||
total = 5,
|
||||
onChange = () => {},
|
||||
color = "#9125E6",
|
||||
bgColor = "#E9D4FF",
|
||||
}) => {
|
||||
// Convert 0–100 to 0–5 scale
|
||||
const displayValue = Math.round((value / 100) * total);
|
||||
|
||||
const handleClick = (index) => {
|
||||
// Convert 0–5 scale back to 0–100 for DB
|
||||
const newValue = Math.round(((index + 1) / total) * 100);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return <div className="flex gap-3 cursor-pointer">
|
||||
{[...Array(total)].map((_, index) => {
|
||||
const isActive = index < displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleClick(index)}
|
||||
className="w-4 h-4 rounded transition-all"
|
||||
style={{
|
||||
backgroundColor: isActive ? color : bgColor,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
};
|
||||
|
||||
export default RatingInput;
|
||||
@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import Progress from "../Progress";
|
||||
|
||||
const SkillInfo = ({ skill, progress, accentColor, bgColor }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={`text-[12px] font-semibold text-gray-900`}>{skill}</p>
|
||||
{progress > 0 && (
|
||||
<Progress
|
||||
progress={(progress / 100) * 5}
|
||||
color={accentColor}
|
||||
bgColor={bgColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SkillSection = ({ skills, accentColor, bgColor }) => {
|
||||
return <div className="grid grid-cols-2 gap-x-5 gap-y-1 mb-5">
|
||||
{skills?.map((skill, index) => (
|
||||
<SkillInfo
|
||||
key={`skill_${index}`}
|
||||
skill={skill.name}
|
||||
progress={skill.progress}
|
||||
accentColor={accentColor}
|
||||
bgColor={bgColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
};
|
||||
|
||||
export default SkillSection;
|
||||
@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
const WorkExperience = ({
|
||||
company,
|
||||
role,
|
||||
duration,
|
||||
durationColor,
|
||||
description,
|
||||
}) => {
|
||||
return <div className="mb-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-[15px] font-semibold text-gray-900">
|
||||
{company}
|
||||
</h3>
|
||||
<p className="text-[15px] text-gray-700 font-medium">{role}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-bold italic" style={{color: durationColor}}>
|
||||
{duration}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 font-medium italic mt-[0.2cqw]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default WorkExperience;
|
||||
@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import TemplateOne from "./TemplateOne";
|
||||
import TemplateTwo from "./TemplateTwo";
|
||||
import TemplateThree from "./TemplateThree";
|
||||
|
||||
const RenderResume = ({
|
||||
templateId,
|
||||
resumeData,
|
||||
colorPalette,
|
||||
containerWidth,
|
||||
}) => {
|
||||
switch (templateId) {
|
||||
case "01":
|
||||
return (
|
||||
<TemplateOne
|
||||
resumeData={resumeData}
|
||||
colorPalette={colorPalette}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
case "02":
|
||||
return (
|
||||
<TemplateTwo
|
||||
resumeData={resumeData}
|
||||
colorPalette={colorPalette}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
case "03":
|
||||
return (
|
||||
<TemplateThree
|
||||
resumeData={resumeData}
|
||||
colorPalette={colorPalette}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TemplateOne
|
||||
resumeData={resumeData}
|
||||
colorPalette={colorPalette}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default RenderResume;
|
||||
@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LuMapPinHouse,
|
||||
LuMail,
|
||||
LuPhone,
|
||||
LuRss,
|
||||
LuGithub,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import { RiLinkedinLine } from "react-icons/ri";
|
||||
import ContactInfo from "../ResumeSections/ContactInfo";
|
||||
import EducationInfo from "../ResumeSections/EducationInfo";
|
||||
import { formatYearMonth } from "../../utils/helper";
|
||||
import LanguageSection from "../ResumeSections/LanguageSection";
|
||||
import WorkExperience from "../ResumeSections/WorkExperience";
|
||||
import ProjectInfo from "../ResumeSections/ProjectInfo";
|
||||
import SkillSection from "../ResumeSections/SkillSection";
|
||||
import CertificationInfo from "../ResumeSections/CertificationInfo";
|
||||
|
||||
const DEFAULT_THEME = ["#EBFDFF", "#A1F4FD", "#CEFAFE", "#00B8DB", "#4A5565"];
|
||||
|
||||
const Title = ({ text, color }) => {
|
||||
return (
|
||||
<div className="relative w-fit mb-2.5">
|
||||
<span
|
||||
className="absolute bottom-0 left-0 w-full h-2"
|
||||
style={{ backgroundColor: color }}
|
||||
></span>
|
||||
<h2 className={`relative text-sm font-bold`}>{text}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateOne = ({ resumeData, colorPalette, containerWidth }) => {
|
||||
const themeColors = colorPalette?.length > 0 ? colorPalette : DEFAULT_THEME;
|
||||
|
||||
const resumeRef = useRef(null);
|
||||
const [baseWidth, setBaseWidth] = useState(800); // Default value
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Calculate the scale factor based on the container width
|
||||
const actualBaseWidth = resumeRef.current.offsetWidth;
|
||||
setBaseWidth(actualBaseWidth); // Get the actual base width
|
||||
setScale(containerWidth / baseWidth);
|
||||
}, [containerWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resumeRef}
|
||||
className="p-3 bg-white"
|
||||
style={{
|
||||
transform: containerWidth > 0 ? `scale(${scale})` : "none",
|
||||
transformOrigin: "top left",
|
||||
width: containerWidth > 0 ? `${baseWidth}px` : "auto", // Keep the original size so scaling works correctly
|
||||
height: "auto",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-8">
|
||||
<div
|
||||
className="col-span-4 py-10"
|
||||
style={{ backgroundColor: themeColors[0] }}
|
||||
>
|
||||
<div className="flex flex-col items-center px-2">
|
||||
<div
|
||||
className="w-[100px] h-[100px] max-w-[110px] max-h-[110px] rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: themeColors[1] }}
|
||||
>
|
||||
{resumeData.profileInfo.profilePreviewUrl ? (
|
||||
<img
|
||||
src={resumeData.profileInfo.profilePreviewUrl}
|
||||
className="w-[90px] h-[90px] rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-[90px] h-[90px] flex items-center justify-center text-5xl rounded-full"
|
||||
style={{ color: themeColors[4] }}
|
||||
>
|
||||
<LuUser />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold mt-3">
|
||||
{resumeData.profileInfo.fullName}
|
||||
</h2>
|
||||
<p className="text-sm text-center">
|
||||
{resumeData.profileInfo.designation}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-6 mx-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ContactInfo
|
||||
icon={<LuMapPinHouse />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.location}
|
||||
/>
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuMail />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.email}
|
||||
/>
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuPhone />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.phone}
|
||||
/>
|
||||
|
||||
{resumeData.contactInfo.linkedin && (
|
||||
<ContactInfo
|
||||
icon={<RiLinkedinLine />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.linkedin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resumeData.contactInfo.github && (
|
||||
<ContactInfo
|
||||
icon={<LuGithub />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.github}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuRss />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.website}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Title text="Education" color={themeColors[1]} />
|
||||
|
||||
{resumeData.education.map((data, index) => (
|
||||
<EducationInfo
|
||||
key={`education_${index}`}
|
||||
degree={data.degree}
|
||||
institution={data.institution}
|
||||
duration={`${formatYearMonth(
|
||||
data.startDate
|
||||
)} - ${formatYearMonth(data.endDate)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Title text="Languages" color={themeColors[1]} />
|
||||
|
||||
<LanguageSection
|
||||
languages={resumeData.languages}
|
||||
accentColor={themeColors[3]}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8 pt-10 mr-10 pb-5">
|
||||
<div>
|
||||
<Title text="Professional Summary" color={themeColors[1]} />
|
||||
<p className="text-sm font-medium">
|
||||
{resumeData.profileInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Work Experiance" color={themeColors[1]} />
|
||||
|
||||
{resumeData.workExperience.map((data, index) => (
|
||||
<WorkExperience
|
||||
key={`work_${index}`}
|
||||
company={data.company}
|
||||
role={data.role}
|
||||
duration={`${formatYearMonth(
|
||||
data.startDate
|
||||
)} - ${formatYearMonth(data.endDate)}`}
|
||||
durationColor={themeColors[4]}
|
||||
description={data.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Projects" color={themeColors[1]} />
|
||||
|
||||
{resumeData.projects.map((project, index) => (
|
||||
<ProjectInfo
|
||||
key={`project_${index}`}
|
||||
title={project.title}
|
||||
description={project.description}
|
||||
githubLink={project.github}
|
||||
liveDemoUrl={project.liveDemo}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Skills" color={themeColors[1]} />
|
||||
|
||||
<SkillSection
|
||||
skills={resumeData.skills}
|
||||
accentColor={themeColors[3]}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Certifications" color={themeColors[1]} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{resumeData.certifications.map((data, index) => (
|
||||
<CertificationInfo
|
||||
key={`cert_${index}`}
|
||||
title={data.title}
|
||||
issuer={data.issuer}
|
||||
year={data.year}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resumeData.interests.length > 0 && resumeData.interests[0] != "" && (
|
||||
<div className="mt-4">
|
||||
<Title text="Interests" color={themeColors[1]} />
|
||||
|
||||
<div className="flex items-center flex-wrap gap-3 mt-4">
|
||||
{resumeData.interests.map((interest, index) => {
|
||||
if (!interest) return null;
|
||||
return (
|
||||
<div
|
||||
key={`interest_${index}`}
|
||||
className="text-[10px] font-medium py-1 px-3 rounded-lg"
|
||||
style={{ backgroundColor: themeColors[2] }}
|
||||
>
|
||||
{interest}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateOne;
|
||||
@ -0,0 +1,264 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LuMapPinHouse,
|
||||
LuMail,
|
||||
LuPhone,
|
||||
LuRss,
|
||||
LuGithub,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import { RiLinkedinLine } from "react-icons/ri";
|
||||
import ContactInfo from "../ResumeSections/ContactInfo";
|
||||
import EducationInfo from "../ResumeSections/EducationInfo";
|
||||
import { formatYearMonth } from "../../utils/helper";
|
||||
import LanguageSection from "../ResumeSections/LanguageSection";
|
||||
import WorkExperience from "../ResumeSections/WorkExperience";
|
||||
import ProjectInfo from "../ResumeSections/ProjectInfo";
|
||||
import SkillSection from "../ResumeSections/SkillSection";
|
||||
import CertificationInfo from "../ResumeSections/CertificationInfo";
|
||||
|
||||
const DEFAULT_THEME = ["#EBFDFF", "#A1F4FD", "#CEFAFE", "#00B8DB", "#4A5565"];
|
||||
|
||||
const Title = ({ text, color }) => {
|
||||
return (
|
||||
<div className="relative w-fit mb-2.5">
|
||||
<span
|
||||
className="absolute bottom-0 left-0 w-full h-2"
|
||||
style={{ backgroundColor: color }}
|
||||
></span>
|
||||
<h2 className={`relative text-sm font-bold`}>{text}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateThree = ({ resumeData, colorPalette, containerWidth }) => {
|
||||
const themeColors = colorPalette?.length > 0 ? colorPalette : DEFAULT_THEME;
|
||||
|
||||
const resumeRef = useRef(null);
|
||||
const [baseWidth, setBaseWidth] = useState(800); // Default value
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Calculate the scale factor based on the container width
|
||||
const actualBaseWidth = resumeRef.current.offsetWidth;
|
||||
setBaseWidth(actualBaseWidth); // Get the actual base width
|
||||
setScale(containerWidth / baseWidth);
|
||||
}, [containerWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resumeRef}
|
||||
className="p-3 bg-white"
|
||||
style={{
|
||||
transform: containerWidth > 0 ? `scale(${scale})` : "none",
|
||||
transformOrigin: "top left",
|
||||
width: containerWidth > 0 ? `${baseWidth}px` : "auto", // Keep the original size so scaling works correctly
|
||||
height: "auto",
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="flex items-start gap-5 px-2 mb-5">
|
||||
<div
|
||||
className="w-[100px] h-[100px] max-w-[105px] max-h-[105px] rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: themeColors[1] }}
|
||||
>
|
||||
{resumeData.profileInfo.profilePreviewUrl ? (
|
||||
<img
|
||||
src={resumeData.profileInfo.profilePreviewUrl}
|
||||
className="w-[90px] h-[90px] rounded-2xl"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-[90px] h-[90px] flex items-center justify-center text-5xl rounded-full"
|
||||
style={{ color: themeColors[4] }}
|
||||
>
|
||||
<LuUser />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-12 items-center">
|
||||
<div className="col-span-8">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{resumeData.profileInfo.fullName}
|
||||
</h2>
|
||||
<p className="text-[15px] font-semibold mb-2">
|
||||
{resumeData.profileInfo.designation}
|
||||
</p>
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuMapPinHouse />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.location}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-4 flex flex-col gap-5 mt-2">
|
||||
<ContactInfo
|
||||
icon={<LuMail />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.email}
|
||||
/>
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuPhone />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.phone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-8">
|
||||
<div
|
||||
className="col-span-4 py-10"
|
||||
style={{ backgroundColor: themeColors[0] }}
|
||||
>
|
||||
|
||||
<div className="my-6 mx-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{resumeData.contactInfo.linkedin && (
|
||||
<ContactInfo
|
||||
icon={<RiLinkedinLine />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.linkedin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resumeData.contactInfo.github && (
|
||||
<ContactInfo
|
||||
icon={<LuGithub />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.github}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuRss />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.website}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Title text="Education" color={themeColors[1]} />
|
||||
|
||||
{resumeData.education.map((data, index) => (
|
||||
<EducationInfo
|
||||
key={`education_${index}`}
|
||||
degree={data.degree}
|
||||
institution={data.institution}
|
||||
duration={`${formatYearMonth(
|
||||
data.startDate
|
||||
)} - ${formatYearMonth(data.endDate)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Title text="Languages" color={themeColors[1]} />
|
||||
|
||||
<LanguageSection
|
||||
languages={resumeData.languages}
|
||||
accentColor={themeColors[3]}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8 pt-10 mr-10 pb-5">
|
||||
<div>
|
||||
<Title text="Professional Summary" color={themeColors[1]} />
|
||||
<p className="text-sm font-medium">
|
||||
{resumeData.profileInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Work Experiance" color={themeColors[1]} />
|
||||
|
||||
{resumeData.workExperience.map((data, index) => (
|
||||
<WorkExperience
|
||||
key={`work_${index}`}
|
||||
company={data.company}
|
||||
role={data.role}
|
||||
duration={`${formatYearMonth(
|
||||
data.startDate
|
||||
)} - ${formatYearMonth(data.endDate)}`}
|
||||
durationColor={themeColors[4]}
|
||||
description={data.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Projects" color={themeColors[1]} />
|
||||
|
||||
{resumeData.projects.map((project, index) => (
|
||||
<ProjectInfo
|
||||
key={`project_${index}`}
|
||||
title={project.title}
|
||||
description={project.description}
|
||||
githubLink={project.github}
|
||||
liveDemoUrl={project.liveDemo}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Skills" color={themeColors[1]} />
|
||||
|
||||
<SkillSection
|
||||
skills={resumeData.skills}
|
||||
accentColor={themeColors[3]}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Certifications" color={themeColors[1]} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{resumeData.certifications.map((data, index) => (
|
||||
<CertificationInfo
|
||||
key={`cert_${index}`}
|
||||
title={data.title}
|
||||
issuer={data.issuer}
|
||||
year={data.year}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resumeData.interests.length > 0 && resumeData.interests[0] != "" && (
|
||||
<div className="mt-4">
|
||||
<Title text="Interests" color={themeColors[1]} />
|
||||
|
||||
<div className="flex items-center flex-wrap gap-3 mt-4">
|
||||
{resumeData.interests.map((interest, index) => {
|
||||
if (!interest) return null;
|
||||
return (
|
||||
<div
|
||||
key={`interest_${index}`}
|
||||
className="text-[10px] font-medium py-1 px-3 rounded-lg"
|
||||
style={{ backgroundColor: themeColors[2] }}
|
||||
>
|
||||
{interest}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateThree;
|
||||
@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LuMapPinHouse,
|
||||
LuMail,
|
||||
LuPhone,
|
||||
LuRss,
|
||||
LuGithub,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import { RiLinkedinLine } from "react-icons/ri";
|
||||
import ContactInfo from "../ResumeSections/ContactInfo";
|
||||
import EducationInfo from "../ResumeSections/EducationInfo";
|
||||
import { formatYearMonth } from "../../utils/helper";
|
||||
import LanguageSection from "../ResumeSections/LanguageSection";
|
||||
import WorkExperience from "../ResumeSections/WorkExperience";
|
||||
import ProjectInfo from "../ResumeSections/ProjectInfo";
|
||||
import SkillSection from "../ResumeSections/SkillSection";
|
||||
import CertificationInfo from "../ResumeSections/CertificationInfo";
|
||||
|
||||
const DEFAULT_THEME = ["#EBFDFF", "#A1F4FD", "#CEFAFE", "#00B8DB", "#4A5565"];
|
||||
|
||||
const Title = ({ text, color }) => {
|
||||
return (
|
||||
<div className="relative w-fit mb-2.5">
|
||||
<span
|
||||
className="absolute bottom-0 left-0 w-full h-2"
|
||||
style={{ backgroundColor: color }}
|
||||
></span>
|
||||
<h2 className={`relative text-sm font-bold`}>{text}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateTwo = ({ resumeData, colorPalette, containerWidth }) => {
|
||||
const themeColors = colorPalette?.length > 0 ? colorPalette : DEFAULT_THEME;
|
||||
|
||||
const resumeRef = useRef(null);
|
||||
const [baseWidth, setBaseWidth] = useState(800); // Default value
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Calculate the scale factor based on the container width
|
||||
const actualBaseWidth = resumeRef.current.offsetWidth;
|
||||
setBaseWidth(actualBaseWidth); // Get the actual base width
|
||||
setScale(containerWidth / baseWidth);
|
||||
}, [containerWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resumeRef}
|
||||
className="p-3 bg-white"
|
||||
style={{
|
||||
transform: containerWidth > 0 ? `scale(${scale})` : "none",
|
||||
transformOrigin: "top left",
|
||||
width: containerWidth > 0 ? `${baseWidth}px` : "auto", // Keep the original size so scaling works correctly
|
||||
height: "auto",
|
||||
}}
|
||||
>
|
||||
<div className="px-10 pt-10 pb-5">
|
||||
<div className="flex items-start gap-5 mb-5">
|
||||
<div
|
||||
className="w-[140px] h-[140px] max-w-[140px] max-h-[140px] rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: themeColors[1] }}
|
||||
>
|
||||
{resumeData.profileInfo.profilePreviewUrl ? (
|
||||
<img
|
||||
src={resumeData.profileInfo.profilePreviewUrl}
|
||||
className="w-[140px] h-[140px] rounded-2xl"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-[140px] h-[140px] flex items-center justify-center text-5xl rounded-full"
|
||||
style={{ color: themeColors[4] }}
|
||||
>
|
||||
<LuUser />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-6">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{resumeData.profileInfo.fullName}
|
||||
</h2>
|
||||
<p className="text-[15px] font-semibold mb-2">
|
||||
{resumeData.profileInfo.designation}
|
||||
</p>
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuMapPinHouse />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.location}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-6 flex flex-col gap-5 mt-2">
|
||||
<ContactInfo
|
||||
icon={<LuMail />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.email}
|
||||
/>
|
||||
|
||||
<ContactInfo
|
||||
icon={<LuPhone />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.phone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-6">
|
||||
{resumeData.contactInfo.linkedin && (
|
||||
<ContactInfo
|
||||
icon={<RiLinkedinLine />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.linkedin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-6">
|
||||
<ContactInfo
|
||||
icon={<LuRss />}
|
||||
iconBG={themeColors[2]}
|
||||
value={resumeData.contactInfo.website}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-10 pb-5">
|
||||
<div>
|
||||
<Title text="Professional Summary" color={themeColors[1]} />
|
||||
<p className="text-sm font-medium">
|
||||
{resumeData.profileInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Work Experiance" color={themeColors[1]} />
|
||||
|
||||
{resumeData.workExperience.map((data, index) => (
|
||||
<WorkExperience
|
||||
key={`work_${index}`}
|
||||
company={data.company}
|
||||
role={data.role}
|
||||
duration={`${formatYearMonth(data.startDate)} - ${formatYearMonth(
|
||||
data.endDate
|
||||
)}`}
|
||||
durationColor={themeColors[4]}
|
||||
description={data.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Projects" color={themeColors[1]} />
|
||||
|
||||
{resumeData.projects.map((project, index) => (
|
||||
<ProjectInfo
|
||||
key={`project_${index}`}
|
||||
title={project.title}
|
||||
description={project.description}
|
||||
githubLink={project.github}
|
||||
liveDemoUrl={project.liveDemo}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Title text="Education" color={themeColors[1]} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{resumeData.education.map((data, index) => (
|
||||
<EducationInfo
|
||||
key={`education_${index}`}
|
||||
degree={data.degree}
|
||||
institution={data.institution}
|
||||
duration={`${formatYearMonth(
|
||||
data.startDate
|
||||
)} - ${formatYearMonth(data.endDate)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Certifications" color={themeColors[1]} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{resumeData.certifications.map((data, index) => (
|
||||
<CertificationInfo
|
||||
key={`cert_${index}`}
|
||||
title={data.title}
|
||||
issuer={data.issuer}
|
||||
year={data.year}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Title text="Skills" color={themeColors[1]} />
|
||||
|
||||
<SkillSection
|
||||
skills={resumeData.skills}
|
||||
accentColor={themeColors[3]}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-10 mt-4">
|
||||
<div className="">
|
||||
<Title text="Languages" color={themeColors[1]} />
|
||||
|
||||
<LanguageSection
|
||||
languages={resumeData.languages}
|
||||
accentColor={themeColors[3]}
|
||||
bgColor={themeColors[2]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resumeData.interests.length > 0 && resumeData.interests[0] != "" && (
|
||||
<div className="">
|
||||
<Title text="Interests" color={themeColors[1]} />
|
||||
|
||||
<div className="flex items-center flex-wrap gap-3 mt-4">
|
||||
{resumeData.interests.map((interest, index) => {
|
||||
if (!interest) return null;
|
||||
return (
|
||||
<div
|
||||
key={`interest_${index}`}
|
||||
className="text-[10px] font-medium py-1 px-3 rounded-lg"
|
||||
style={{ backgroundColor: themeColors[2] }}
|
||||
>
|
||||
{interest}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateTwo;
|
||||
14
frontend/resume-builder/src/components/StepProgress.jsx
Normal file
14
frontend/resume-builder/src/components/StepProgress.jsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
const StepProgress = ({progress}) => {
|
||||
return (
|
||||
<div className="w-full bg-purple-50 h-1 overflow-hidden rounded-[2px]">
|
||||
<div
|
||||
className="h-1 bg-linear-to-r from-purple-500/85 to-purple-700 transition-all rounded"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StepProgress
|
||||
31
frontend/resume-builder/src/components/Tabs.jsx
Normal file
31
frontend/resume-builder/src/components/Tabs.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
|
||||
const Tabs = ({tabs, activeTab, setActiveTab}) => {
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className="flex">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
className={`relative px-3 md:px-4 py-2 text-sm font-medium ${
|
||||
activeTab === tab.label
|
||||
? "text-primary"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
} cursor-pointer`}
|
||||
onClick={() => setActiveTab(tab.label)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-[14px] font-semibold text-purple-700">{tab.label}</span>
|
||||
|
||||
</div>
|
||||
{activeTab === tab.label && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-linear-to-r from-purple-500/85 to-purple-700"></div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tabs
|
||||
@ -0,0 +1,16 @@
|
||||
import React, { useContext } from "react";
|
||||
import { UserContext } from "../../context/userContext";
|
||||
import Navbar from "./Navbar";
|
||||
|
||||
const DashboardLayout = ({ activeMenu, children }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
return (
|
||||
<div>
|
||||
<Navbar activeMenu={activeMenu} />
|
||||
|
||||
{user && <div className="container mx-auto pt-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
||||
19
frontend/resume-builder/src/components/layouts/Navbar.jsx
Normal file
19
frontend/resume-builder/src/components/layouts/Navbar.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ProfileInfoCard from "../Cards/ProfileInfoCard";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Navbar = () => {
|
||||
return <div className="h-16 bg-white border boredr-b border-gray-200/50 backdrop-blur-[2px] py-2.5 px-4 md:px-0 sticky top-0 z-30">
|
||||
<div className="container mx-auto flex items-center justify-between gap-5">
|
||||
<Link to='/dashboard'>
|
||||
<h2 className="text-lg md:text-xl font-medium text-black leading-5">
|
||||
Resume Builder
|
||||
</h2>
|
||||
</Link>
|
||||
|
||||
<ProfileInfoCard />
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
53
frontend/resume-builder/src/context/userContext.jsx
Normal file
53
frontend/resume-builder/src/context/userContext.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React, { createContext, useState, useEffect } from "react";
|
||||
import axiosInstance from "../utils/axiosInstance";
|
||||
import { API_PATHS } from "../utils/apiPaths";
|
||||
|
||||
export const UserContext = createContext();
|
||||
|
||||
const UserProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true); // New state to track loading
|
||||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
|
||||
const accessToken = localStorage.getItem("token");
|
||||
if (!accessToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get(API_PATHS.AUTH.GET_PROFILE);
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
console.error("User not authenticated", error);
|
||||
clearUser();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
const updateUser = (userData) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem("token", userData.token); // Save token
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const clearUser = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("token");
|
||||
};
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, loading, updateUser, clearUser }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProvider;
|
||||
69
frontend/resume-builder/src/index.css
Normal file
69
frontend/resume-builder/src/index.css
Normal file
@ -0,0 +1,69 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Urbanist:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-display: "Urbanist", sans-serif;
|
||||
--breakpoint-3xl: 1920px;
|
||||
--color-primary: #9328E7;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #fcfbfc;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(100, 100, 100, 0.4);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Animate text with a shine effect */
|
||||
@keyframes text-shine {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-text-shine {
|
||||
animation: text-shine 3s ease-in-out infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
.input-box {
|
||||
@apply w-full flex justify-between gap-3 text-sm text-black bg-gray-50/50 rounded px-4 py-3 mb-4 mt-3 border border-gray-100 outline-none focus-within:border-purple-300;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply w-full text-sm font-medium text-white bg-black shadow-lg shadow-purple-600/5 p-[10px] rounded-md my-1 hover:bg-purple-600/15 hover:text-black cursor-pointer;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@apply flex items-center gap-2 text-[13px] font-semibold text-white bg-linear-to-r from-purple-500/85 to-purple-700 px-5 py-1.5 rounded cursor-pointer;
|
||||
}
|
||||
|
||||
.btn-small-light {
|
||||
@apply flex items-center gap-2 text-[12px] font-semibold text-purple-800 bg-purple-600/15 border border-purple-50 hover:border-purple-400 px-3 py-1.5 rounded cursor-pointer;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full text-sm text-black outline-none bg-white border border-slate-100 px-2.5 py-3 rounded-md mt-2 placeholder:text-gray-500 focus-within:border-purple-300;
|
||||
}
|
||||
10
frontend/resume-builder/src/main.jsx
Normal file
10
frontend/resume-builder/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
102
frontend/resume-builder/src/pages/Auth/Login.jsx
Normal file
102
frontend/resume-builder/src/pages/Auth/Login.jsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Input from "../../components/Inputs/Input";
|
||||
import { validateEmail } from "../../utils/helper";
|
||||
import { UserContext } from "../../context/userContext";
|
||||
import axiosInstance from "../../utils/axiosInstance";
|
||||
import { API_PATHS } from "../../utils/apiPaths";
|
||||
|
||||
const Login = ({ setCurrentPage }) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const { updateUser } = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handle Login Form Submit
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError("Please enter the password");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
|
||||
//Login API Call
|
||||
try {
|
||||
const response = await axiosInstance.post(API_PATHS.AUTH.LOGIN, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const { token } = response.data;
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
updateUser(response.data);
|
||||
navigate("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.data.message) {
|
||||
setError(error.response.data.message);
|
||||
} else {
|
||||
setError("Something went wrong. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[90vw] md:w-[33vw] p-7 flex flex-col justify-center">
|
||||
<h3 className="text-lg font-semibold text-black">Welcome Back</h3>
|
||||
<p className="text-xs text-slate-700 mt-[5px] mb-6">
|
||||
Please enter your details to log in
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={({ target }) => setEmail(target.value)}
|
||||
label="Email Address"
|
||||
placeholder="john@example.com"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={password}
|
||||
onChange={({ target }) => setPassword(target.value)}
|
||||
label="Password"
|
||||
placeholder="Min 8 Characters"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
{error && <p className="text-red-500 text-xs pb-2.5">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
LOGIN
|
||||
</button>
|
||||
|
||||
<p className="text-[13px] text-slate-800 mt-3">
|
||||
Don’t have an account?{" "}
|
||||
<button
|
||||
className="font-medium text-primary underline cursor-pointer"
|
||||
onClick={() => {
|
||||
setCurrentPage("signup");
|
||||
}}
|
||||
>
|
||||
SignUp
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
135
frontend/resume-builder/src/pages/Auth/SignUp.jsx
Normal file
135
frontend/resume-builder/src/pages/Auth/SignUp.jsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Input from "../../components/Inputs/Input";
|
||||
import { validateEmail } from "../../utils/helper";
|
||||
import ProfilePhotoSelector from "../../components/Inputs/ProfilePhotoSelector";
|
||||
import axiosInstance from "../../utils/axiosInstance";
|
||||
import { API_PATHS } from "../../utils/apiPaths";
|
||||
import { UserContext } from "../../context/userContext";
|
||||
import uploadImage from "../../utils/uploadImage";
|
||||
|
||||
const SignUp = ({setCurrentPage}) => {
|
||||
const [profilePic, setProfilePic] = useState(null);
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const { updateUser } = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handle SignUp Form Submit
|
||||
const handleSignUp = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
let profileImageUrl = "";
|
||||
|
||||
if (!fullName) {
|
||||
setError("Please enter full name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError("Please enter the password");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
|
||||
//SignUp API Call
|
||||
try {
|
||||
// Upload image if present
|
||||
if (profilePic) {
|
||||
const imgUploadRes = await uploadImage(profilePic);
|
||||
profileImageUrl = imgUploadRes.imageUrl || "";
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post(API_PATHS.AUTH.REGISTER, {
|
||||
name: fullName,
|
||||
email,
|
||||
password,
|
||||
profileImageUrl,
|
||||
});
|
||||
|
||||
const { token } = response.data;
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
updateUser(response.data);
|
||||
navigate("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.data.message) {
|
||||
setError(error.response.data.message);
|
||||
} else {
|
||||
setError("Something went wrong. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[90vw] md:w-[33vw] p-7 flex flex-col justify-center">
|
||||
<h3 className="text-lg font-semibold text-black">Create an Account</h3>
|
||||
<p className="text-xs text-slate-700 mt-[5px] mb-6">
|
||||
Join us today by entering your details below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSignUp}>
|
||||
|
||||
<ProfilePhotoSelector image={profilePic} setImage={setProfilePic} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-2">
|
||||
<Input
|
||||
value={fullName}
|
||||
onChange={({ target }) => setFullName(target.value)}
|
||||
label="Full Name"
|
||||
placeholder="John"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={email}
|
||||
onChange={({ target }) => setEmail(target.value)}
|
||||
label="Email Address"
|
||||
placeholder="john@example.com"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={password}
|
||||
onChange={({ target }) => setPassword(target.value)}
|
||||
label="Password"
|
||||
placeholder="Min 8 Characters"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-xs pb-2.5">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
SIGN UP
|
||||
</button>
|
||||
|
||||
<p className="text-[13px] text-slate-800 mt-3">
|
||||
Already an account?{" "}
|
||||
<button
|
||||
className="font-medium text-primary underline cursor-pointer"
|
||||
onClick={() => {
|
||||
setCurrentPage("login");
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUp
|
||||
67
frontend/resume-builder/src/pages/Home/CreateResumeForm.jsx
Normal file
67
frontend/resume-builder/src/pages/Home/CreateResumeForm.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Input from "../../components/Inputs/Input";
|
||||
import axiosInstance from '../../utils/axiosInstance';
|
||||
import { API_PATHS } from '../../utils/apiPaths';
|
||||
|
||||
const CreateResumeForm = () => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handle Create Resume
|
||||
const handleCreateResume = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title) {
|
||||
setError("Please resume title");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
|
||||
//Create Resume API Call
|
||||
try {
|
||||
const response = await axiosInstance.post(API_PATHS.RESUME.CREATE, {
|
||||
title,
|
||||
});
|
||||
|
||||
if (response.data?._id) {
|
||||
navigate(`/resume/${response.data?._id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.data.message) {
|
||||
setError(error.response.data.message);
|
||||
} else {
|
||||
setError("Something went wrong. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="w-[90vw] md:w-[70vh] p-7 flex flex-col justify-center">
|
||||
<h3 className="text-lg font-semibold text-black">Create New Resume</h3>
|
||||
<p className="text-xs text-slate-700 mt-[5px] mb-3">
|
||||
Give your resume a title to get started. You can edit all details later.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreateResume}>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={({ target }) => setTitle(target.value)}
|
||||
label="Resume Title"
|
||||
placeholder="Eg: Mike's Resume"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
{error && <p className="text-red-500 text-xs pb-2.5">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
Create Resume
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateResumeForm
|
||||
73
frontend/resume-builder/src/pages/Home/Dashboard.jsx
Normal file
73
frontend/resume-builder/src/pages/Home/Dashboard.jsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import axiosInstance from "../../utils/axiosInstance";
|
||||
import { API_PATHS } from "../../utils/apiPaths";
|
||||
import DashboardLayout from "../../components/layouts/DashboardLayout";
|
||||
import {LuCirclePlus} from 'react-icons/lu'
|
||||
import moment from 'moment'
|
||||
import ResumeSummaryCard from "../../components/Cards/ResumeSummaryCard";
|
||||
import CreateResumeForm from "./CreateResumeForm";
|
||||
import Modal from "../../components/Modal";
|
||||
|
||||
const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [allResumes, setAllResumes] = useState(null);
|
||||
|
||||
const fetchAllResumes = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get(API_PATHS.RESUME.GET_ALL);
|
||||
setAllResumes(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching resumes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllResumes();
|
||||
}, []);
|
||||
|
||||
return <DashboardLayout>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 md:gap-7 pt-1 pb-6 px-4 md:px-0">
|
||||
<div
|
||||
className="h-[300px] flex flex-col gap-5 items-center justify-center bg-white rounded-lg border border-purple-100 hover:border-purple-300 hover:bg-purple-50/5 cursor-pointer"
|
||||
onClick={() => setOpenCreateModal(true)}
|
||||
>
|
||||
<div className="w-12 h-12 flex items-center justify-center bg-purple-200/60 rounded-2xl">
|
||||
<LuCirclePlus className="text-xl text-purple-500" />
|
||||
</div>
|
||||
|
||||
<h3 className="font-medium text-gray-800">Add New Resume</h3>
|
||||
</div>
|
||||
|
||||
{allResumes?.map((resume) => (
|
||||
<ResumeSummaryCard
|
||||
key={resume?._id}
|
||||
imgUrl={resume?.thumbnailLink || null}
|
||||
title={resume.title}
|
||||
lastUpdated={
|
||||
resume?.updatedAt
|
||||
? moment(resume.updatedAt).format("Do MMM YYYY")
|
||||
: ""
|
||||
}
|
||||
onSelect={()=>navigate(`/resume/${resume?._id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={openCreateModal}
|
||||
onClose={() => {
|
||||
setOpenCreateModal(false);
|
||||
}}
|
||||
hideHeader
|
||||
>
|
||||
<div>
|
||||
<CreateResumeForm />
|
||||
</div>
|
||||
</Modal>
|
||||
</DashboardLayout>;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
130
frontend/resume-builder/src/pages/LandingPage.jsx
Normal file
130
frontend/resume-builder/src/pages/LandingPage.jsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
|
||||
import HERO_IMG from "../assets/hero-img.png";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Login from "./Auth/Login";
|
||||
import SignUp from "./Auth/SignUp";
|
||||
import Modal from "../components/Modal";
|
||||
import { UserContext } from "../context/userContext";
|
||||
import ProfileInfoCard from "../components/Cards/ProfileInfoCard";
|
||||
|
||||
const LandingPage = () => {
|
||||
const { user } = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [openAuthModal, setOpenAuthModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState("login");
|
||||
|
||||
const handleCTA = () => {
|
||||
if (!user) {
|
||||
setOpenAuthModal(true);
|
||||
} else {
|
||||
navigate("/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-full bg-white">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-center mb-16">
|
||||
<div className="text-xl font-bold">Resume Builder</div>
|
||||
{user ? (
|
||||
<ProfileInfoCard />
|
||||
) : (
|
||||
<button
|
||||
className="bg-purple-100 text-sm font-semibold text-black px-7 py-2.5 rounded-lg hover:bg-gray-800 hover:text-white transition-colors cursor-pointer"
|
||||
onClick={() => setOpenAuthModal(true)}
|
||||
>
|
||||
Login / Sign Up
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="flex flex-col md:flex-row items-center">
|
||||
<div className="w-full md:w-1/2 pr-4 mb-8 md:mb-0">
|
||||
<h1 className="text-5xl font-bold mb-6 leading-tight">
|
||||
Build Your{" "}
|
||||
<span className="text-transparent bg-clip-text bg-[radial-gradient(circle,_#7182ff_0%,_#3cff52_100%)] bg-[length:200%_200%] animate-text-shine">
|
||||
Resume Effortlessly
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg text-gray-700 mb-8">
|
||||
Craft a standout resume in minutes with our smart and intuitive
|
||||
resume builder.
|
||||
</p>
|
||||
<button
|
||||
className="bg-black text-sm font-semibold text-white px-8 py-3 rounded-lg hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
onClick={handleCTA}
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full md:w-1/2">
|
||||
<img
|
||||
src={HERO_IMG}
|
||||
alt="Hero Image"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-5">
|
||||
<h2 className="text-2xl font-bold text-center mb-12">
|
||||
Features That Make You Shine
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-gray-50 p-6 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<h3 className="text-lg font-semibold mb-3">Easy Editing</h3>
|
||||
<p className="text-gray-600">
|
||||
Update your resume sections with live preview and instant
|
||||
formatting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-6 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Beautiful Templates
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Choose from modern, professional templates that are easy to
|
||||
customize.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-6 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<h3 className="text-lg font-semibold mb-3">One-Click Export</h3>
|
||||
<p className="text-gray-600">
|
||||
Download your resume instantly as a high-quality PDF with one
|
||||
click.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="text-sm bg-gray-50 text-secondary text-center p-5 mt-5">
|
||||
Made with ❤️... Happy Coding
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={openAuthModal}
|
||||
onClose={() => {
|
||||
setOpenAuthModal(false);
|
||||
setCurrentPage("login");
|
||||
}}
|
||||
hideHeader
|
||||
>
|
||||
<div>
|
||||
{currentPage === "login" && <Login setCurrentPage={setCurrentPage} />}
|
||||
{currentPage === "signup" && (
|
||||
<SignUp setCurrentPage={setCurrentPage} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
718
frontend/resume-builder/src/pages/ResumeUpdate/EditResume.jsx
Normal file
718
frontend/resume-builder/src/pages/ResumeUpdate/EditResume.jsx
Normal file
@ -0,0 +1,718 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
LuArrowLeft,
|
||||
LuCircleAlert,
|
||||
LuDownload,
|
||||
LuPalette,
|
||||
LuSave,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import toast from "react-hot-toast";
|
||||
import DashboardLayout from "../../components/layouts/DashboardLayout";
|
||||
import TitleInput from "../../components/Inputs/TitleInput";
|
||||
import { useReactToPrint } from "react-to-print";
|
||||
import axiosInstance from "../../utils/axiosInstance";
|
||||
import { API_PATHS } from "../../utils/apiPaths";
|
||||
import StepProgress from "../../components/StepProgress";
|
||||
import ProfileInfoForm from "./Forms/ProfileInfoForm";
|
||||
import ContactInfoForm from "./Forms/ContactInfoForm";
|
||||
import WorkExperienceForm from "./Forms/WorkExperienceForm";
|
||||
import EducationDetailsForm from "./Forms/EducationDetailsForm";
|
||||
import SkillsInfoForm from "./Forms/SkillsInfoForm";
|
||||
import ProjectsDetailFrom from "./Forms/ProjectsDetailFrom";
|
||||
import CertificationInfoFrom from "./Forms/CertificationInfoFrom";
|
||||
import AdditionalInfoFrom from "./Forms/AdditionalInfoFrom";
|
||||
import RenderResume from "../../components/ResumeTemplates/RenderResume";
|
||||
import { captureElementAsImage, dataURLtoFile, fixTailwindColors } from "../../utils/helper";
|
||||
import ThemeSelector from "./ThemeSelector";
|
||||
import Modal from "../../components/Modal";
|
||||
|
||||
const EditResume = () => {
|
||||
const { resumeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const resumeRef = useRef(null);
|
||||
const resumeDownloadRef = useRef(null);
|
||||
|
||||
const [baseWidth, setBaseWidth] = useState(800);
|
||||
|
||||
const [openThemeSelector, setOpenThemeSelector] = useState(false);
|
||||
|
||||
const [openPreviewModal, setOpenPreviewModal] = useState(false);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState("profile-info");
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [resumeData, setResumeData] = useState({
|
||||
title: "",
|
||||
thumbnailLink: "",
|
||||
profileInfo: {
|
||||
profileImg: null,
|
||||
profilePreviewUrl: "",
|
||||
fullName: "",
|
||||
designation: "",
|
||||
summary: "",
|
||||
},
|
||||
template: {
|
||||
theme: "",
|
||||
colorPalette: "",
|
||||
},
|
||||
contactInfo: {
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
linkedin: "",
|
||||
github: "",
|
||||
website: "",
|
||||
},
|
||||
workExperience: [
|
||||
{
|
||||
company: "",
|
||||
role: "",
|
||||
startDate: "", // e.g. "2022-01"
|
||||
endDate: "", // e.g. "2023-12"
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
education: [
|
||||
{
|
||||
degree: "",
|
||||
institution: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: "",
|
||||
progress: 0, // percentage value (0-100)
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
title: "",
|
||||
description: "",
|
||||
github: "",
|
||||
liveDemo: "",
|
||||
},
|
||||
],
|
||||
certifications: [
|
||||
{
|
||||
title: "",
|
||||
issuer: "",
|
||||
year: "",
|
||||
},
|
||||
],
|
||||
languages: [
|
||||
{
|
||||
name: "",
|
||||
progress: 0, // percentage value (0-100)
|
||||
},
|
||||
],
|
||||
interests: [""],
|
||||
});
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Validate Inputs
|
||||
const validateAndNext = (e) => {
|
||||
const errors = [];
|
||||
|
||||
switch (currentPage) {
|
||||
case "profile-info":
|
||||
const { fullName, designation, summary } = resumeData.profileInfo;
|
||||
if (!fullName.trim()) errors.push("Full Name is required");
|
||||
if (!designation.trim()) errors.push("Designation is required");
|
||||
if (!summary.trim()) errors.push("Summary is required");
|
||||
break;
|
||||
|
||||
case "contact-info":
|
||||
const { email, phone } = resumeData.contactInfo;
|
||||
if (!email.trim() || !/^\S+@\S+\.\S+$/.test(email))
|
||||
errors.push("Valid email is required.");
|
||||
if (!phone.trim())
|
||||
errors.push("Valid 10-digit phone number is required");
|
||||
break;
|
||||
|
||||
case "work-experience":
|
||||
resumeData.workExperience.forEach(
|
||||
({ company, role, startDate, endDate }, index) => {
|
||||
if (!company.trim())
|
||||
errors.push(`Company is required in experience ${index + 1}`);
|
||||
if (!role.trim())
|
||||
errors.push(`Role is required in experience ${index + 1}`);
|
||||
if (!startDate || !endDate)
|
||||
errors.push(
|
||||
`Start and End dates are required in experience ${index + 1}`
|
||||
);
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "education-info":
|
||||
resumeData.education.forEach(
|
||||
({ degree, institution, startDate, endDate }, index) => {
|
||||
if (!degree.trim())
|
||||
errors.push(`Degree is required in education ${index + 1}`);
|
||||
if (!institution.trim())
|
||||
errors.push(`Institution is required in education ${index + 1}`);
|
||||
if (!startDate || !endDate)
|
||||
errors.push(
|
||||
`Start and End dates are required in education ${index + 1}`
|
||||
);
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "skills":
|
||||
resumeData.skills.forEach(({ name, progress }, index) => {
|
||||
if (!name.trim())
|
||||
errors.push(`Skill name is required in skill ${index + 1}`);
|
||||
if (progress < 1 || progress > 100)
|
||||
errors.push(
|
||||
`Skill progress must be between 1 and 100 in skill ${index + 1}`
|
||||
);
|
||||
});
|
||||
break;
|
||||
|
||||
case "projects":
|
||||
resumeData.projects.forEach(({ title, description }, index) => {
|
||||
if (!title.trim())
|
||||
errors.push(`Project title is required in project ${index + 1}`);
|
||||
if (!description.trim())
|
||||
errors.push(
|
||||
`Project description is required in project ${index + 1}`
|
||||
);
|
||||
});
|
||||
break;
|
||||
|
||||
case "certifications":
|
||||
resumeData.certifications.forEach(({ title, issuer }, index) => {
|
||||
if (!title.trim())
|
||||
errors.push(
|
||||
`Certification title is required in certification ${index + 1}`
|
||||
);
|
||||
if (!issuer.trim())
|
||||
errors.push(`Issuer is required in certification ${index + 1}`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "additionalInfo":
|
||||
if (
|
||||
resumeData.languages.length === 0 ||
|
||||
!resumeData.languages[0].name?.trim()
|
||||
) {
|
||||
errors.push("At least one language is required");
|
||||
}
|
||||
|
||||
if (
|
||||
resumeData.interests.length === 0 ||
|
||||
!resumeData.interests[0]?.trim()
|
||||
) {
|
||||
errors.push("At least one interest is required");
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setErrorMsg(errors.join(", "));
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to next step
|
||||
setErrorMsg("");
|
||||
goToNextStep();
|
||||
};
|
||||
|
||||
// Function to navigate to the next page
|
||||
const goToNextStep = () => {
|
||||
const pages = [
|
||||
"profile-info",
|
||||
"contact-info",
|
||||
"work-experience",
|
||||
"education-info",
|
||||
"skills",
|
||||
"projects",
|
||||
"certifications",
|
||||
"additionalInfo",
|
||||
];
|
||||
|
||||
if (currentPage === "additionalInfo") setOpenPreviewModal(true);
|
||||
|
||||
const currentIndex = pages.indexOf(currentPage);
|
||||
if (currentIndex !== -1 && currentIndex < pages.length - 1) {
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentPage(pages[nextIndex]);
|
||||
|
||||
// Set progress as percentage
|
||||
const percent = Math.round((nextIndex / (pages.length - 1)) * 100);
|
||||
setProgress(percent);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
// Function to navigate to the previous page
|
||||
const goBack = () => {
|
||||
const pages = [
|
||||
"profile-info",
|
||||
"contact-info",
|
||||
"work-experience",
|
||||
"education-info",
|
||||
"skills",
|
||||
"projects",
|
||||
"certifications",
|
||||
"additionalInfo",
|
||||
];
|
||||
|
||||
if (currentPage === "profile-info") navigate("/dashboard");
|
||||
|
||||
const currentIndex = pages.indexOf(currentPage);
|
||||
if (currentIndex > 0) {
|
||||
const prevIndex = currentIndex - 1;
|
||||
setCurrentPage(pages[prevIndex]);
|
||||
|
||||
// Update progress
|
||||
const percent = Math.round((prevIndex / (pages.length - 1)) * 100);
|
||||
setProgress(percent);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (currentPage) {
|
||||
case "profile-info":
|
||||
return (
|
||||
<ProfileInfoForm
|
||||
profileData={resumeData?.profileInfo}
|
||||
updateSection={(key, value) => {
|
||||
updateSection("profileInfo", key, value);
|
||||
}}
|
||||
onNext={validateAndNext}
|
||||
/>
|
||||
);
|
||||
|
||||
case "contact-info":
|
||||
return (
|
||||
<ContactInfoForm
|
||||
contactInfo={resumeData?.contactInfo}
|
||||
updateSection={(key, value) => {
|
||||
updateSection("contactInfo", key, value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "work-experience":
|
||||
return (
|
||||
<WorkExperienceForm
|
||||
workExperience={resumeData?.workExperience}
|
||||
updateArrayItem={(index, key, value) => {
|
||||
updateArrayItem("workExperience", index, key, value);
|
||||
}}
|
||||
addArrayItem={(newItem) => addArrayItem("workExperience", newItem)}
|
||||
removeArrayItem={(index) =>
|
||||
removeArrayItem("workExperience", index)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case "education-info":
|
||||
return (
|
||||
<EducationDetailsForm
|
||||
educationInfo={resumeData?.education}
|
||||
updateArrayItem={(index, key, value) => {
|
||||
updateArrayItem("education", index, key, value);
|
||||
}}
|
||||
addArrayItem={(newItem) => addArrayItem("education", newItem)}
|
||||
removeArrayItem={(index) => removeArrayItem("education", index)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "skills":
|
||||
return (
|
||||
<SkillsInfoForm
|
||||
skillsInfo={resumeData?.skills}
|
||||
updateArrayItem={(index, key, value) => {
|
||||
updateArrayItem("skills", index, key, value);
|
||||
}}
|
||||
addArrayItem={(newItem) => addArrayItem("skills", newItem)}
|
||||
removeArrayItem={(index) => removeArrayItem("skills", index)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "projects":
|
||||
return (
|
||||
<ProjectsDetailFrom
|
||||
projectInfo={resumeData?.projects}
|
||||
updateArrayItem={(index, key, value) => {
|
||||
updateArrayItem("projects", index, key, value);
|
||||
}}
|
||||
addArrayItem={(newItem) => addArrayItem("projects", newItem)}
|
||||
removeArrayItem={(index) => removeArrayItem("projects", index)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "certifications":
|
||||
return (
|
||||
<CertificationInfoFrom
|
||||
certifications={resumeData?.certifications}
|
||||
updateArrayItem={(index, key, value) => {
|
||||
updateArrayItem("certifications", index, key, value);
|
||||
}}
|
||||
addArrayItem={(newItem) => addArrayItem("certifications", newItem)}
|
||||
removeArrayItem={(index) =>
|
||||
removeArrayItem("certifications", index)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case "additionalInfo":
|
||||
return (
|
||||
<AdditionalInfoFrom
|
||||
languages={resumeData.languages}
|
||||
interests={resumeData.interests}
|
||||
updateArrayItem={(section, index, key, value) =>
|
||||
updateArrayItem(section, index, key, value)
|
||||
}
|
||||
addArrayItem={(section, newItem) => addArrayItem(section, newItem)}
|
||||
removeArrayItem={(section, index) =>
|
||||
removeArrayItem(section, index)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update simple nested object (like profileInfo, contactInfo, etc.)
|
||||
const updateSection = (section, key, value) => {
|
||||
setResumeData((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Update array item (like workExperience[0], skills[1], etc.)
|
||||
const updateArrayItem = (section, index, key, value) => {
|
||||
setResumeData((prev) => {
|
||||
const updatedArray = [...prev[section]];
|
||||
|
||||
if (key === null) {
|
||||
updatedArray[index] = value; // for simple strings like in `interests`
|
||||
} else {
|
||||
updatedArray[index] = {
|
||||
...updatedArray[index],
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[section]: updatedArray,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Add item to array
|
||||
const addArrayItem = (section, newItem) => {
|
||||
setResumeData((prev) => ({
|
||||
...prev,
|
||||
[section]: [...prev[section], newItem],
|
||||
}));
|
||||
};
|
||||
|
||||
// Remove item from array
|
||||
const removeArrayItem = (section, index) => {
|
||||
setResumeData((prev) => {
|
||||
const updatedArray = [...prev[section]];
|
||||
updatedArray.splice(index, 1);
|
||||
return {
|
||||
...prev,
|
||||
[section]: updatedArray,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch resume info by ID
|
||||
const fetchResumeDetailsById = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get(
|
||||
API_PATHS.RESUME.GET_BY_ID(resumeId)
|
||||
);
|
||||
|
||||
if (response.data && response.data.profileInfo) {
|
||||
const resumeInfo = response.data;
|
||||
|
||||
setResumeData((prevState) => ({
|
||||
...prevState,
|
||||
title: resumeInfo?.title || "Untitled",
|
||||
template: resumeInfo?.template || prevState?.template,
|
||||
profileInfo: resumeInfo?.profileInfo || prevState?.profileInfo,
|
||||
contactInfo: resumeInfo?.contactInfo || prevState?.contactInfo,
|
||||
workExperience:
|
||||
resumeInfo?.workExperience || prevState?.workExperience,
|
||||
education: resumeInfo?.education || prevState?.education,
|
||||
skills: resumeInfo?.skills || prevState?.skills,
|
||||
projects: resumeInfo?.projects || prevState?.projects,
|
||||
certifications:
|
||||
resumeInfo?.certifications || prevState?.certifications,
|
||||
languages: resumeInfo?.languages || prevState?.languages,
|
||||
interests: resumeInfo?.interests || prevState?.interests,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching resumes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// upload thumbnail and resume profile img
|
||||
const uploadResumeImages = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
fixTailwindColors(resumeRef.current);
|
||||
const imageDataUrl = await captureElementAsImage(resumeRef.current);
|
||||
|
||||
// Convert base64 to File
|
||||
const thumbnailFile = dataURLtoFile(
|
||||
imageDataUrl,
|
||||
`resume-${resumeId}.png`
|
||||
);
|
||||
|
||||
const profileImageFile = resumeData?.profileInfo?.profileImg || null;
|
||||
|
||||
const formData = new FormData();
|
||||
if (profileImageFile) formData.append("profileImage", profileImageFile);
|
||||
if (thumbnailFile) formData.append("thumbnail", thumbnailFile);
|
||||
|
||||
const uploadResponse = await axiosInstance.put(
|
||||
API_PATHS.RESUME.UPLOAD_IMAGES(resumeId),
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
|
||||
const { thumbnailLink, profilePreviewUrl } = uploadResponse.data;
|
||||
|
||||
console.log("RESUME_DATA___", resumeData);
|
||||
|
||||
// Call the second API to update other resume data
|
||||
await updateResumeDetails(thumbnailLink, profilePreviewUrl);
|
||||
|
||||
toast.success("Resume Updated Successfully!");
|
||||
navigate("/dashboard");
|
||||
} catch (error) {
|
||||
console.error("Error uploading images:", error);
|
||||
toast.error("Failed to upload images");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateResumeDetails = async (thumbnailLink, profilePreviewUrl) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await axiosInstance.put(
|
||||
API_PATHS.RESUME.UPDATE(resumeId),
|
||||
{
|
||||
...resumeData,
|
||||
thumbnailLink: thumbnailLink || "",
|
||||
profileInfo: {
|
||||
...resumeData.profileInfo,
|
||||
profilePreviewUrl: profilePreviewUrl || "",
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error capturing image:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete Resume
|
||||
const handleDeleteResume = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await axiosInstance.delete(API_PATHS.RESUME.DELETE(resumeId));
|
||||
toast.success('Resume Deleted Successfully')
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
console.error("Error capturing image:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// download resume
|
||||
const reactToPrintFn = useReactToPrint({ contentRef: resumeDownloadRef });
|
||||
|
||||
// Function to update baseWidth based on the resume container size
|
||||
const updateBaseWidth = () => {
|
||||
if (resumeRef.current) {
|
||||
setBaseWidth(resumeRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateBaseWidth();
|
||||
window.addEventListener("resize", updateBaseWidth);
|
||||
|
||||
if (resumeId) {
|
||||
fetchResumeDetailsById();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateBaseWidth);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto">
|
||||
<div className="flex items-center justify-between gap-5 bg-white rounded-lg border border-purple-100 py-3 px-4 mb-4">
|
||||
<TitleInput
|
||||
title={resumeData.title}
|
||||
setTitle={(value) =>
|
||||
setResumeData((prevState) => ({
|
||||
...prevState,
|
||||
title: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="btn-small-light"
|
||||
onClick={() => setOpenThemeSelector(true)}
|
||||
>
|
||||
<LuPalette className="text-[16px]" />
|
||||
<span className="hidden md:block">Change Theme</span>
|
||||
</button>
|
||||
|
||||
<button className="btn-small-light" onClick={handleDeleteResume}>
|
||||
<LuTrash2 className="text-[16px]" />
|
||||
<span className="hidden md:block">Delete</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-small-light"
|
||||
onClick={() => setOpenPreviewModal(true)}
|
||||
>
|
||||
<LuDownload className="text-[16px]" />
|
||||
<span className="hidden md:block">Preview & Download</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="bg-white rounded-lg border border-purple-100 overflow-hidden">
|
||||
<StepProgress progress={progress} />
|
||||
|
||||
{renderForm()}
|
||||
|
||||
<div className="mx-5">
|
||||
{errorMsg && (
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-amber-600 bg-amber-100 px-2 py-0.5 my-1 rounded">
|
||||
<LuCircleAlert className="text-md" /> {errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-end gap-3 mt-3 mb-5">
|
||||
<button
|
||||
className="btn-small-light"
|
||||
onClick={goBack}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuArrowLeft className="text-[16px]" />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className="btn-small-light"
|
||||
onClick={uploadResumeImages}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuSave className="text-[16px]" />
|
||||
{isLoading ? "Updating..." : "Save & Exit"}
|
||||
</button>
|
||||
<button
|
||||
className="btn-small"
|
||||
onClick={validateAndNext}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{currentPage === "additionalInfo" && (
|
||||
<LuDownload className="text-[16px]" />
|
||||
)}
|
||||
|
||||
{currentPage === "additionalInfo"
|
||||
? "Preview & Download"
|
||||
: "Next"}
|
||||
{currentPage !== "additionalInfo" && (
|
||||
<LuArrowLeft className="text-[16px] rotate-180" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={resumeRef} className="h-[100vh]">
|
||||
{/* Resume Template */}
|
||||
<RenderResume
|
||||
templateId={resumeData?.template?.theme || ""}
|
||||
resumeData={resumeData}
|
||||
colorPalette={resumeData?.template?.colorPalette || []}
|
||||
containerWidth={baseWidth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={openThemeSelector}
|
||||
onClose={() => setOpenThemeSelector(false)}
|
||||
title="Change Theme"
|
||||
>
|
||||
<div className="w-[90vw] h-[80vh]">
|
||||
<ThemeSelector
|
||||
selectedTheme={resumeData?.template}
|
||||
setSelectedTheme={(value) => {
|
||||
setResumeData((prevState) => ({
|
||||
...prevState,
|
||||
template: value || prevState.template,
|
||||
}));
|
||||
}}
|
||||
resumeData={null}
|
||||
onClose={() => setOpenThemeSelector(false)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={openPreviewModal}
|
||||
onClose={() => setOpenPreviewModal(false)}
|
||||
title={resumeData.title}
|
||||
showActionBtn
|
||||
actionBtnText="Download"
|
||||
actionBtnIcon={<LuDownload className="text-[16px]" />}
|
||||
onActionClick={() => reactToPrintFn()}
|
||||
>
|
||||
<div ref={resumeDownloadRef} className="w-[98vw] h-[90vh]">
|
||||
<RenderResume
|
||||
templateId={resumeData?.template?.theme || ""}
|
||||
resumeData={resumeData}
|
||||
colorPalette={resumeData?.template?.colorPalette || []}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditResume;
|
||||
@ -0,0 +1,116 @@
|
||||
import React from "react";
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import RatingInput from "../../../components/ResumeSections/RatingInput";
|
||||
|
||||
const AdditionalInfoFrom = ({
|
||||
languages,
|
||||
interests,
|
||||
updateArrayItem,
|
||||
addArrayItem,
|
||||
removeArrayItem,
|
||||
}) => {
|
||||
return <div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Additional Info</h2>
|
||||
|
||||
{/* Languages Section */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Languages</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{languages?.map((lang, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 p-4 rounded-lg relative"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<Input
|
||||
label="Language"
|
||||
placeholder="e.g. English"
|
||||
value={lang.name || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem("languages", index, "name", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-7 block">
|
||||
Proficiency
|
||||
</label>
|
||||
<RatingInput
|
||||
value={lang.progress || 0}
|
||||
onChange={(value) =>
|
||||
updateArrayItem("languages", index, "progress", value)
|
||||
}
|
||||
total={5}
|
||||
activeColor="#0ea5e9"
|
||||
inactiveColor="#e0f2fe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{languages.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem("languages", index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() => addArrayItem("languages", { name: "", progress: 0 })}
|
||||
>
|
||||
<LuPlus /> Add Language
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{console.log(interests)}
|
||||
|
||||
{/* Interests Section */}
|
||||
<div className="mt-8 mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Interests</h3>
|
||||
<div className="flex flex-col">
|
||||
{interests?.map((interest, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative rounded-lg"
|
||||
>
|
||||
<Input
|
||||
placeholder="e.g. Reading"
|
||||
value={interest || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem("interests", index, null, target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{interests.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-6.5 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem("interests", index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() => addArrayItem("interests", "")}
|
||||
>
|
||||
<LuPlus /> Add Interest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default AdditionalInfoFrom;
|
||||
@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
|
||||
const CertificationInfoFrom = ({
|
||||
certifications,
|
||||
updateArrayItem,
|
||||
addArrayItem,
|
||||
removeArrayItem,
|
||||
}) => {
|
||||
return <div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Certifications</h2>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 mb-3">
|
||||
{certifications.map((cert, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200/80 p-4 rounded-lg relative"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Certificate Title"
|
||||
placeholder="Fullstack Web Developer"
|
||||
type="text"
|
||||
value={cert.title || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "title", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Issuer"
|
||||
placeholder="Coursera / Google / etc."
|
||||
type="text"
|
||||
value={cert.issuer || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "issuer", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Year"
|
||||
placeholder="2024"
|
||||
type="text"
|
||||
value={cert.year || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "year", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{certifications.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem(index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
addArrayItem({
|
||||
title: "",
|
||||
issuer: "",
|
||||
year: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPlus /> Add Certification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default CertificationInfoFrom;
|
||||
@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
|
||||
const ContactInfoForm = ({contactInfo, updateSection}) => {
|
||||
return (
|
||||
<div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Contact Information
|
||||
</h2>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
label="Address"
|
||||
placeholder="Short Address"
|
||||
type="text"
|
||||
value={contactInfo.location || ""}
|
||||
onChange={({ target }) => updateSection("location", target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
value={contactInfo.email || ""}
|
||||
onChange={({ target }) => updateSection("email", target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Phone Number"
|
||||
placeholder="9876543210"
|
||||
type="text"
|
||||
value={contactInfo.phone || ""}
|
||||
onChange={({ target }) => updateSection("phone", target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="LinkedIn"
|
||||
placeholder="https://linkedin.com/in/username"
|
||||
type="text"
|
||||
value={contactInfo.linkedin || ""}
|
||||
onChange={({ target }) => updateSection("linkedin", target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="GitHub"
|
||||
placeholder="https://github.com/username"
|
||||
type="text"
|
||||
value={contactInfo.github || ""}
|
||||
onChange={({ target }) => updateSection("github", target.value)}
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Portfolio / Website"
|
||||
placeholder="https://yourwebsite.com"
|
||||
type="text"
|
||||
value={contactInfo.website || ""}
|
||||
onChange={({ target }) => updateSection("website", target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactInfoForm
|
||||
@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
|
||||
const EducationDetailsForm = ({
|
||||
educationInfo,
|
||||
updateArrayItem,
|
||||
addArrayItem,
|
||||
removeArrayItem,
|
||||
}) => {
|
||||
return <div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Education</h2>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 mb-3">
|
||||
{educationInfo.map((education, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200/80 p-4 rounded-lg relative"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Degree"
|
||||
placeholder="B.Tech in Computer Science"
|
||||
type="text"
|
||||
value={education.degree || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "degree", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Institution"
|
||||
placeholder="XYZ University"
|
||||
type="text"
|
||||
value={education.institution || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "institution", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Start Date"
|
||||
type="month"
|
||||
value={education.startDate || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "startDate", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="End Date"
|
||||
type="month"
|
||||
value={education.endDate || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "endDate", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{educationInfo.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem(index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
addArrayItem({
|
||||
degree: "",
|
||||
institution: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPlus /> Add Education
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default EducationDetailsForm;
|
||||
@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import ProfilePhotoSelector from "../../../components/Inputs/ProfilePhotoSelector";
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
|
||||
const ProfileInfoForm = ({profileData, updateSection}) => {
|
||||
return (
|
||||
<div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Personal Information
|
||||
</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
<ProfilePhotoSelector
|
||||
image={profileData?.profileImg || profileData?.profilePreviewUrl}
|
||||
setImage={(value) => updateSection("profileImg", value)}
|
||||
preview={profileData?.profilePreviewUrl}
|
||||
setPreview={(value) => updateSection("profilePreviewUrl", value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
value={profileData.fullName || ""}
|
||||
onChange={({ target }) => updateSection("fullName", target.value)}
|
||||
label="Full Name"
|
||||
placeholder="John"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={profileData.designation || ""}
|
||||
onChange={({ target }) =>
|
||||
updateSection("designation", target.value)
|
||||
}
|
||||
label="Designation"
|
||||
placeholder="UI Designer"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div className="col-span-2 mt-3">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Summary
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
placeholder="Short Introduction"
|
||||
className="form-input"
|
||||
rows={4}
|
||||
value={profileData.summary || ""}
|
||||
onChange={({ target }) => updateSection("summary", target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileInfoForm
|
||||
@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
|
||||
const ProjectsDetailFrom = ({
|
||||
projectInfo,
|
||||
updateArrayItem,
|
||||
addArrayItem,
|
||||
removeArrayItem,
|
||||
}) => {
|
||||
return <div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Projects</h2>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 mb-3">
|
||||
{projectInfo.map((project, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200/80 p-4 rounded-lg relative"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
label="Project Title"
|
||||
placeholder="Portfolio Website"
|
||||
type="text"
|
||||
value={project.title || ""}
|
||||
onChange={
|
||||
({ target }) =>
|
||||
updateArrayItem(index, "title", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="Short description about the project"
|
||||
className="form-input w-full mt-1"
|
||||
rows={3}
|
||||
value={project.description || ""}
|
||||
onChange={
|
||||
({ target }) =>
|
||||
updateArrayItem(index, "description", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="GitHub Link"
|
||||
placeholder="https://github.com/username/project"
|
||||
type="url"
|
||||
value={project.github || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "github", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Live Demo URL"
|
||||
placeholder="https://yourproject.live"
|
||||
type="url"
|
||||
value={project.liveDemo || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "liveDemo", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectInfo.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem(index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
addArrayItem({
|
||||
title: "",
|
||||
description: "",
|
||||
github: "",
|
||||
liveDemo: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPlus /> Add Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default ProjectsDetailFrom;
|
||||
@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import RatingInput from '../../../components/ResumeSections/RatingInput';
|
||||
|
||||
const SkillsInfoForm = ({skillsInfo, updateArrayItem, addArrayItem, removeArrayItem}) => {
|
||||
return (
|
||||
<div className="px-5 pt-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Skills</h2>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 mb-3">
|
||||
{skillsInfo.map((skill, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200/80 p-4 rounded-lg relative"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Skill Name"
|
||||
placeholder="JavaScript"
|
||||
type="text"
|
||||
value={skill.name || ""}
|
||||
onChange={
|
||||
({ target }) =>
|
||||
updateArrayItem(index, "name", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label className="text-[13px] text-slate-800 mb-1">
|
||||
Proficiency ({skill.progress / 20 || 0}/5)
|
||||
</label>
|
||||
<div className="mt-5">
|
||||
<RatingInput
|
||||
value={skill.progress || 0}
|
||||
total={5}
|
||||
onChange={(newValue) =>
|
||||
updateArrayItem(index, "progress", newValue)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skillsInfo.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem(index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
addArrayItem({
|
||||
name: "",
|
||||
progress: 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPlus /> Add Skill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkillsInfoForm
|
||||
@ -0,0 +1,103 @@
|
||||
import React from 'react'
|
||||
import Input from "../../../components/Inputs/Input";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
|
||||
const WorkExperienceForm = ({workExperience, updateArrayItem, addArrayItem, removeArrayItem}) => {
|
||||
return (
|
||||
<div className="px-5 pt-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Work Experience</h2>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 mb-3">
|
||||
{workExperience.map((experience, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200/80 p-4 rounded-lg relative"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Company"
|
||||
placeholder="ABC Corp"
|
||||
type="text"
|
||||
value={experience.company || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "company", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Role"
|
||||
placeholder="Frontend Developer"
|
||||
type="text"
|
||||
value={experience.role || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "role", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Start Date"
|
||||
type="month"
|
||||
value={experience.startDate || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "startDate", target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="End Date"
|
||||
type="month"
|
||||
value={experience.endDate || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "endDate", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="What did you do in this role?"
|
||||
className="form-input w-full mt-1"
|
||||
rows={3}
|
||||
value={experience.description || ""}
|
||||
onChange={({ target }) =>
|
||||
updateArrayItem(index, "description", target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{workExperience.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-sm text-red-600 hover:underline cursor-pointer"
|
||||
onClick={() => removeArrayItem(index)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-2 px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm font-medium hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
addArrayItem({
|
||||
company: "",
|
||||
role: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
description: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPlus /> Add Work Experience
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkExperienceForm
|
||||
127
frontend/resume-builder/src/pages/ResumeUpdate/ThemeSelector.jsx
Normal file
127
frontend/resume-builder/src/pages/ResumeUpdate/ThemeSelector.jsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DUMMY_RESUME_DATA,
|
||||
resumeTemplates,
|
||||
themeColorPalette,
|
||||
} from "../../utils/data";
|
||||
import { LuCircleCheckBig } from "react-icons/lu";
|
||||
import Tabs from "../../components/Tabs";
|
||||
import TemplateCard from "../../components/Cards/TemplateCard";
|
||||
import RenderResume from "../../components/ResumeTemplates/RenderResume";
|
||||
|
||||
const TAB_DATA = [{ label: "Templates" }, { label: "Color Palettes" }];
|
||||
|
||||
const ThemeSelector = ({
|
||||
selectedTheme,
|
||||
setSelectedTheme,
|
||||
resumeData,
|
||||
onClose,
|
||||
}) => {
|
||||
const resumeRef = useRef(null);
|
||||
const [baseWidth, setBaseWidth] = useState(800);
|
||||
|
||||
const [tabValue, setTabValue] = useState("Templates");
|
||||
const [selectedColorPalette, setSelectedColorPalette] = useState({
|
||||
colors: selectedTheme?.colorPalette,
|
||||
index: -1,
|
||||
});
|
||||
const [selectedTemplate, setSelectedTemplate] = useState({
|
||||
theme: selectedTheme?.theme || "",
|
||||
index: -1,
|
||||
});
|
||||
|
||||
// Handle Theme Change
|
||||
const handleThemeSelection = () => {
|
||||
setSelectedTheme({
|
||||
colorPalette: selectedColorPalette?.colors,
|
||||
theme: selectedTemplate?.theme,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateBaseWidth = () => {
|
||||
if (resumeRef.current) {
|
||||
setBaseWidth(resumeRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateBaseWidth();
|
||||
window.addEventListener("resize", updateBaseWidth);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateBaseWidth);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className="container mx-auto px-2 md:px-0">
|
||||
<div className="flex items-center justify-between mb-5 mt-2">
|
||||
<Tabs tabs={TAB_DATA} activeTab={tabValue} setActiveTab={setTabValue} />
|
||||
|
||||
<button
|
||||
className="btn-small-light"
|
||||
onClick={() => handleThemeSelection()}
|
||||
>
|
||||
<LuCircleCheckBig className="text-[16px]" />
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<div className="col-span-12 md:col-span-5 bg-white">
|
||||
<div className="grid grid-cols-2 gap-5 max-h-[80vh] overflow-scroll custom-scrollbar md:pr-5">
|
||||
{tabValue === "Templates" &&
|
||||
resumeTemplates.map((template, index) => (
|
||||
<TemplateCard
|
||||
key={`templates_${index}`}
|
||||
thumbnailImg={template.thumbnailImg}
|
||||
isSelected={selectedTemplate?.index === index}
|
||||
onSelect={() =>
|
||||
setSelectedTemplate({ theme: template.id, index })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tabValue === "Color Palettes" &&
|
||||
themeColorPalette.themeOne.map((colors, index) => (
|
||||
<ColorPalette
|
||||
key={`palette_${index}`}
|
||||
colors={colors}
|
||||
isSelected={selectedColorPalette?.index === index}
|
||||
onSelect={() => setSelectedColorPalette({ colors, index })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-7 bg-white -mt-3" ref={resumeRef}>
|
||||
<RenderResume
|
||||
templateId={selectedTemplate?.theme || ""}
|
||||
resumeData={resumeData || DUMMY_RESUME_DATA}
|
||||
containerWidth={baseWidth}
|
||||
colorPalette={selectedColorPalette?.colors || []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
|
||||
const ColorPalette = ({ colors, isSelected, onSelect }) => {
|
||||
return (
|
||||
<div
|
||||
className={`h-28 bg-purple-50 flex rounded-lg overflow-hidden border-2 ${
|
||||
isSelected ? "border-purple-500" : "border-none"
|
||||
}`}
|
||||
>
|
||||
{colors.map((color, index) => (
|
||||
<div
|
||||
key={`color_${index}`}
|
||||
className="flex-1"
|
||||
style={{ backgroundColor: colors[index] }}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
frontend/resume-builder/src/utils/apiPaths.js
Normal file
23
frontend/resume-builder/src/utils/apiPaths.js
Normal file
@ -0,0 +1,23 @@
|
||||
export const BASE_URL = "http://localhost:8000";
|
||||
|
||||
// utils/apiPaths.js
|
||||
export const API_PATHS = {
|
||||
AUTH: {
|
||||
REGISTER: "/api/auth/register", // Signup
|
||||
LOGIN: "/api/auth/login", // Authenticate user & return JWT token
|
||||
GET_PROFILE: "/api/auth/profile", // Get logged-in user details
|
||||
},
|
||||
|
||||
RESUME: {
|
||||
CREATE: "/api/resume", // POST - Create a new resume
|
||||
GET_ALL: "/api/resume", // GET - Get all resumes of logged-in user
|
||||
GET_BY_ID: (id) => `/api/resume/${id}`, // GET - Get a specific resume
|
||||
UPDATE: (id) => `/api/resume/${id}`, // PUT - Update a resume
|
||||
DELETE: (id) => `/api/resume/${id}`, // DELETE - Delete a resume
|
||||
UPLOAD_IMAGES: (id) => `/api/resume/${id}/upload-images`, // PUT - Upload Thumbnail and Resume profile img
|
||||
},
|
||||
|
||||
IMAGE: {
|
||||
UPLOAD_IMAGE: "api/auth/upload-image",
|
||||
},
|
||||
};
|
||||
48
frontend/resume-builder/src/utils/axiosInstance.js
Normal file
48
frontend/resume-builder/src/utils/axiosInstance.js
Normal file
@ -0,0 +1,48 @@
|
||||
import axios from "axios";
|
||||
import { BASE_URL } from "./apiPaths";
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Request Interceptor
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const accessToken = localStorage.getItem("token");
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response Interceptor
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Handle common errors globally
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
// Redirect to login page
|
||||
window.location.href = "/";
|
||||
} else if (error.response.status === 500) {
|
||||
console.error("Server error. Please try again later.");
|
||||
}
|
||||
} else if (error.code === "ECONNABORTED") {
|
||||
console.error("Request timeout. Please try again.");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
153
frontend/resume-builder/src/utils/data.js
Normal file
153
frontend/resume-builder/src/utils/data.js
Normal file
@ -0,0 +1,153 @@
|
||||
import TEMPLATE_ONE_IMG from '../assets/template-one.png'
|
||||
import TEMPLATE_TWO_IMG from '../assets/template-two.png'
|
||||
import TEMPLATE_THREE_IMG from '../assets/template-three.png'
|
||||
|
||||
export const resumeTemplates = [
|
||||
{
|
||||
id:'01',
|
||||
thumbnailImg: TEMPLATE_ONE_IMG,
|
||||
colorPaletteCode: 'themeOne'
|
||||
},
|
||||
{
|
||||
id:'02',
|
||||
thumbnailImg: TEMPLATE_TWO_IMG,
|
||||
colorPaletteCode: 'themeTwo'
|
||||
},
|
||||
{
|
||||
id:'03',
|
||||
thumbnailImg: TEMPLATE_THREE_IMG,
|
||||
colorPaletteCode: 'themeThree'
|
||||
},
|
||||
]
|
||||
|
||||
export const themeColorPalette = {
|
||||
themeOne: [
|
||||
["#EBFDFF", "#A1F4FD", "#CEFAFE", "#00B8DB", "#4A5565"],
|
||||
|
||||
["#E9FBF8", "#B4EFE7", "#93E2DA", "#2AC9A0", "#3D4C5A"],
|
||||
["#F5F4FF", "#E0DBFF", "#C9C2F8", "#8579D1", "#4B4B5C"],
|
||||
["#F0FAFF", "#D6F0FF", "#AFDEFF", "#3399FF", "#445361"],
|
||||
["#FFF5F7", "#FFE0EC", "#FAC6D4", "#F6729C", "#5A5A5A"],
|
||||
["#F9FAFB", "#E4E7EB", "#CBD5E0", "#7F9CF5", "#2D3748"],
|
||||
|
||||
["#F4FFFD", "#D3FDF2", "#B0E9D4", "#34C79D", "#384C48"],
|
||||
["#FFF7F0", "#FFE6D9", "#FFD2BA", "#FF9561", "#4C4743"],
|
||||
["#F9FCFF", "#E3F0F9", "#C0DDEE", "#6CA6CF", "#46545E"],
|
||||
["#FFFDF6", "#FFF4D7", "#FFE7A0", "#FFD000", "#57534E"],
|
||||
["#EFFCFF", "#C8F0FF", "#99E0FF", "#007BA7", "#2B3A42"],
|
||||
|
||||
["#F7F7F7", "#E4E4E4", "#CFCFCF", "#4A4A4A", "#222222"],
|
||||
["#E3F2FD", "#90CAF9", "#a8d2f4", "#1E88E5", "#0D47A1"],
|
||||
],
|
||||
};
|
||||
|
||||
export const DUMMY_RESUME_DATA = {
|
||||
profileInfo: {
|
||||
profileImg: null,
|
||||
previewUrl: "",
|
||||
fullName: "John Doe",
|
||||
designation: "Senior Software Engineer",
|
||||
summary:
|
||||
"Passionate and results-driven developer with 6+ years of experience building full-stack web applications using modern technologies like React, Node.js, and MongoDB.",
|
||||
},
|
||||
contactInfo: {
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1234567890",
|
||||
location:'#12 Anywhere, Any City, Any Country',
|
||||
linkedin: "https://linkedin.com/timetoprogram",
|
||||
github: "https://github.com/timetoprogram",
|
||||
website: "https://timetoprogram.com",
|
||||
},
|
||||
workExperience: [
|
||||
{
|
||||
company: "Tech Solutions",
|
||||
role: "Senior Frontend Engineer",
|
||||
startDate: "2022-03",
|
||||
endDate: "2025-04",
|
||||
description:
|
||||
"Leading the frontend team to build scalable enterprise applications using React, Tailwind CSS, and TypeScript.",
|
||||
},
|
||||
{
|
||||
company: "Coding Dev",
|
||||
role: "Full Stack Developer",
|
||||
startDate: "2020-01",
|
||||
endDate: "2022-02",
|
||||
description:
|
||||
"Worked on cross-functional teams developing full-stack solutions with React, Node.js, and MongoDB. Improved performance by 30% through code optimization.",
|
||||
},
|
||||
{
|
||||
company: "Startup Company",
|
||||
role: "Junior Web Developer",
|
||||
startDate: "2018-06",
|
||||
endDate: "2019-12",
|
||||
description:
|
||||
"Built responsive websites for startups and small businesses. Maintained legacy code and collaborated with designers to enhance UX/UI.",
|
||||
},
|
||||
],
|
||||
education: [
|
||||
{
|
||||
degree: "M.Sc. Software Engineering",
|
||||
institution: "Tech University",
|
||||
startDate: "2021-08",
|
||||
endDate: "2023-06",
|
||||
},
|
||||
{
|
||||
degree: "B.Sc. Computer Science",
|
||||
institution: "State University",
|
||||
startDate: "2017-08",
|
||||
endDate: "2021-05",
|
||||
},
|
||||
{
|
||||
degree: "High School Diploma",
|
||||
institution: "Central High School",
|
||||
startDate: "2015-06",
|
||||
endDate: "2017-05",
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{ name: "JavaScript", progress: 95 },
|
||||
{ name: "React", progress: 90 },
|
||||
{ name: "Node.js", progress: 85 },
|
||||
{ name: "TypeScript", progress: 80 },
|
||||
{ name: "MongoDB", progress: 75 },
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
title: "Project Manager App",
|
||||
description:
|
||||
"A task and team management app built with MERN stack. Includes user roles, real-time notifications, and drag-and-drop task boards.",
|
||||
github: "https://github.com/timetoprogram/project-manager-app",
|
||||
},
|
||||
{
|
||||
title: "E-Commerce Platform",
|
||||
description:
|
||||
"An e-commerce site built with Next.js and Stripe integration. Supports cart, payments, order history, and admin dashboard.",
|
||||
liveDemo: "https://ecommerce-demo.timetoprogram.com",
|
||||
},
|
||||
{
|
||||
title: "Blog CMS",
|
||||
description:
|
||||
"A custom CMS for blogging using Express and React. Includes WYSIWYG editor, markdown support, and user management.",
|
||||
github: "https://github.com/timetoprogram/blog-cms",
|
||||
liveDemo: "https://blogcms.timetoprogram.dev",
|
||||
},
|
||||
],
|
||||
certifications: [
|
||||
{
|
||||
title: "Full Stack Web Developer",
|
||||
issuer: "Udemy",
|
||||
year: "2023",
|
||||
},
|
||||
{
|
||||
title: "React Advanced Certification",
|
||||
issuer: "Coursera",
|
||||
year: "2022",
|
||||
},
|
||||
],
|
||||
languages: [
|
||||
{ name: "English", progress: 100 },
|
||||
{ name: "Spanish", progress: 70 },
|
||||
{ name: "French", progress: 40 },
|
||||
],
|
||||
interests: ["Reading", "Open Source Contribution", "Hiking"],
|
||||
};
|
||||
111
frontend/resume-builder/src/utils/helper.js
Normal file
111
frontend/resume-builder/src/utils/helper.js
Normal file
@ -0,0 +1,111 @@
|
||||
import moment from 'moment'
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
export const validateEmail = (email) => {
|
||||
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return regex.test(email);
|
||||
};
|
||||
|
||||
// get lightest average color
|
||||
export const getLightColorFromImage = (imageUrl) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if imageUrl is valid
|
||||
if (!imageUrl || typeof imageUrl !== 'string') {
|
||||
return reject(new Error('Invalid image URL'));
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
|
||||
// If not a base64 string, set crossOrigin (important for CORS)
|
||||
if (!imageUrl.startsWith('data:')) {
|
||||
img.crossOrigin = 'anonymous';
|
||||
}
|
||||
|
||||
img.src = imageUrl;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
|
||||
for (let i = 0; i < imageData.length; i += 4) {
|
||||
const red = imageData[i];
|
||||
const green = imageData[i + 1];
|
||||
const blue = imageData[i + 2];
|
||||
const brightness = (red + green + blue) / 3;
|
||||
|
||||
// Only count light pixels (tweak threshold as needed)
|
||||
if (brightness > 180) {
|
||||
r += red;
|
||||
g += green;
|
||||
b += blue;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
resolve('#ffffff'); // fallback if no bright pixels found
|
||||
} else {
|
||||
r = Math.round(r / count);
|
||||
g = Math.round(g / count);
|
||||
b = Math.round(b / count);
|
||||
resolve(`rgb(${r}, ${g}, ${b})`);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
console.error('❌ Failed to load image:', e);
|
||||
reject(new Error('Image could not be loaded or is blocked by CORS.'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Eg: Mar 2025
|
||||
export function formatYearMonth(yearMonth) {
|
||||
return yearMonth ? moment(yearMonth, "YYYY-MM").format("MMM YYYY") : "";
|
||||
}
|
||||
|
||||
export const fixTailwindColors = (element) => {
|
||||
const elements = element.querySelectorAll("*");
|
||||
|
||||
elements.forEach((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
|
||||
["color", "backgroundColor", "borderColor"].forEach((prop) => {
|
||||
const value = style[prop];
|
||||
if (value.includes("oklch")) {
|
||||
el.style[prop] = "#000"; // or any safe fallback
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// convert component to image
|
||||
export async function captureElementAsImage(element) {
|
||||
if (!element) throw new Error("No element provided");
|
||||
|
||||
const canvas = await html2canvas(element);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// Utility to convert base64 data URL to a File object
|
||||
export const dataURLtoFile = (dataUrl, fileName) => {
|
||||
const arr = dataUrl.split(",");
|
||||
const mime = arr[0].match(/:(.*?);/)[1];
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
|
||||
return new File([u8arr], fileName, { type: mime });
|
||||
};
|
||||
22
frontend/resume-builder/src/utils/uploadImage.js
Normal file
22
frontend/resume-builder/src/utils/uploadImage.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { API_PATHS } from './apiPaths';
|
||||
import axiosInstance from './axiosInstance';
|
||||
|
||||
const uploadImage = async (imageFile) => {
|
||||
const formData = new FormData();
|
||||
// Append image file to form data
|
||||
formData.append('image', imageFile);
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post(API_PATHS.IMAGE.UPLOAD_IMAGE, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data', // Set header for file upload
|
||||
},
|
||||
});
|
||||
return response.data; // Return response data
|
||||
} catch (error) {
|
||||
console.error('Error uploading the image:', error);
|
||||
throw error; // Rethrow error for handling
|
||||
}
|
||||
};
|
||||
|
||||
export default uploadImage;
|
||||
Reference in New Issue
Block a user