In JavaScript, debounce is a way to limit how often a function gets called. It helps prevent rapid or repeated function executions by introducing a delay. This is useful for tasks like handling user input, where you want to wait for a pause before triggering an action to avoid unnecessary processing.
const debounce = (onChange) => {
let timeout;
return (e) => {
const form = e.currentTarget.form;
clearTimeout(timeout);
timeout = setTimeout(() => {
onChange(form);
}, 2000);
};
};
<FormRow
type="search"
name="search"
defaultValue={search}
onChange={debounce((form) => {
submit(form);
})}
/>;
- create PageBtnContainer
JobsContainer.jsx
import Job from "./Job";
import Wrapper from "../assets/wrappers/JobsContainer";
import PageBtnContainer from "./PageBtnContainer";
import { useAllJobsContext } from "../pages/AllJobs";
const JobsContainer = () => {
const { data } = useAllJobsContext();
const { jobs, totalJobs, numOfPages } = data;
if (jobs.length === 0) {
return (
<Wrapper>
<h2>No jobs to display...</h2>
</Wrapper>
);
}
return (
<Wrapper>
<h5>
{totalJobs} job{jobs.length > 1 && "s"} found
</h5>
<div className="jobs">
{jobs.map((job) => {
return <Job key={job._id} {...job} />;
})}
</div>
{numOfPages > 1 && <PageBtnContainer />}
</Wrapper>
);
};
export default JobsContainer;
import { HiChevronDoubleLeft, HiChevronDoubleRight } from "react-icons/hi";
import Wrapper from "../assets/wrappers/PageBtnContainer";
import { useLocation, Link, useNavigate } from "react-router-dom";
import { useAllJobsContext } from "../pages/AllJobs";
const PageBtnContainer = () => {
const {
data: { numOfPages, currentPage },
} = useAllJobsContext();
const { search, pathname } = useLocation();
const navigate = useNavigate();
const pages = Array.from({ length: numOfPages }, (_, index) => index + 1);
const handlePageChange = (pageNumber) => {
const searchParams = new URLSearchParams(search);
searchParams.set("page", pageNumber);
navigate(`${pathname}?${searchParams.toString()}`);
};
return (
<Wrapper>
<button
className="btn prev-btn"
onClick={() => {
let prevPage = currentPage - 1;
if (prevPage < 1) prevPage = numOfPages;
handlePageChange(prevPage);
}}
>
<HiChevronDoubleLeft />
prev
</button>
<div className="btn-container">
{pages.map((pageNumber) => (
<button
className={`btn page-btn ${pageNumber === currentPage && "active"}`}
key={pageNumber}
onClick={() => handlePageChange(pageNumber)}
>
{pageNumber}
</button>
))}
</div>
<button
className="btn next-btn"
onClick={() => {
let nextPage = currentPage + 1;
if (nextPage > numOfPages) nextPage = 1;
handlePageChange(nextPage);
}}
>
next
<HiChevronDoubleRight />
</button>
</Wrapper>
);
};
export default PageBtnContainer;
import { HiChevronDoubleLeft, HiChevronDoubleRight } from "react-icons/hi";
import Wrapper from "../assets/wrappers/PageBtnContainer";
import { useLocation, Link, useNavigate } from "react-router-dom";
import { useAllJobsContext } from "../pages/AllJobs";
const PageBtnContainer = () => {
const {
data: { numOfPages, currentPage },
} = useAllJobsContext();
const { search, pathname } = useLocation();
const navigate = useNavigate();
const handlePageChange = (pageNumber) => {
const searchParams = new URLSearchParams(search);
searchParams.set("page", pageNumber);
navigate(`${pathname}?${searchParams.toString()}`);
};
const addPageButton = ({ pageNumber, activeClass }) => {
return (
<button
className={`btn page-btn ${activeClass && "active"}`}
key={pageNumber}
onClick={() => handlePageChange(pageNumber)}
>
{pageNumber}
</button>
);
};
const renderPageButtons = () => {
const pageButtons = [];
// Add the first page button
pageButtons.push(
addPageButton({ pageNumber: 1, activeClass: currentPage === 1 })
);
// Add the dots before the current page if there are more than 3 pages
if (currentPage > 3) {
pageButtons.push(
<span className="page-btn dots" key="dots-1">
....
</span>
);
}
// one before current page
if (currentPage !== 1 && currentPage !== 2) {
pageButtons.push(
addPageButton({ pageNumber: currentPage - 1, activeClass: false })
);
}
// Add the current page button
if (currentPage !== 1 && currentPage !== numOfPages) {
pageButtons.push(
addPageButton({ pageNumber: currentPage, activeClass: true })
);
}
// one after current page
if (currentPage !== numOfPages && currentPage !== numOfPages - 1) {
pageButtons.push(
addPageButton({ pageNumber: currentPage + 1, activeClass: false })
);
}
if (currentPage < numOfPages - 2) {
pageButtons.push(
<span className=" page-btn dots" key="dots+1">
....
</span>
);
}
// Add the last page button
pageButtons.push(
addPageButton({
pageNumber: numOfPages,
activeClass: currentPage === numOfPages,
})
);
return pageButtons;
};
return (
<Wrapper>
<button
className="prev-btn"
onClick={() => {
let prevPage = currentPage - 1;
if (prevPage < 1) prevPage = numOfPages;
handlePageChange(prevPage);
}}
>
<HiChevronDoubleLeft />
prev
</button>
<div className="btn-container">{renderPageButtons()}</div>
<button
className="btn next-btn"
onClick={() => {
let nextPage = currentPage + 1;
if (nextPage > numOfPages) nextPage = 1;
handlePageChange(nextPage);
}}
>
next
<HiChevronDoubleRight />
</button>
</Wrapper>
);
};
export default PageBtnContainer;
wrappers/PageBtnContainer.js
import styled from "styled-components";
const Wrapper = styled.section`
height: 6rem;
margin-top: 2rem;
display: flex;
align-items: center;
justify-content: end;
flex-wrap: wrap;
gap: 1rem;
.btn-container {
background: var(--background-secondary-color);
border-radius: var(--border-radius);
display: flex;
}
.page-btn {
background: transparent;
border-color: transparent;
width: 50px;
height: 40px;
font-weight: 700;
font-size: 1.25rem;
color: var(--primary-500);
border-radius: var(--border-radius);
cursor:pointer:
}
.active{
background:var(--primary-500);
color: var(--white);
}
.prev-btn,.next-btn{
background: var(--background-secondary-color);
border-color: transparent;
border-radius: var(--border-radius);
width: 100px;
height: 40px;
color: var(--primary-500);
text-transform:capitalize;
letter-spacing:var(--letter-spacing);
display:flex;
align-items:center;
justify-content:center;
gap:0.5rem;
cursor:pointer;
}
.prev-btn:hover,.next-btn:hover{
background:var(--primary-500);
color: var(--white);
transition:var(--transition);
}
.dots{
display:grid;
place-items:center;
cursor:text;
}
`;
export default Wrapper;
- remove default values from inputs in Register and Login
- navigate to client and build front-end
cd client && npm run build
-
copy/paste all the files/folders
- from client/dist
- to server(root)/public
-
in server.js point to index.html
app.get("*", (req, res) => {
res.sendFile(path.resolve(__dirname, "./public", "index.html"));
});
- sign up of for account
- create git repository
- add script
- change path
package.json
"scripts": {
"setup-production-app": "npm i && cd client && npm i && npm run build",
},
server.js
app.use(express.static(path.resolve(__dirname, "./client/dist")));
app.get("*", (req, res) => {
res.sendFile(path.resolve(__dirname, "./client/dist", "index.html"));
});
- remove client/dist and client/node_modules
- remove node_modules and package-lock.json (optional)
- run "npm run setup-production-app", followed by "node server"
- change build command on render
npm run setup-production-app
- push up to github
- remove public folder
npm i [email protected]
middleware/multerMiddleware.js
import multer from "multer";
import DataParser from "datauri/parser.js";
import path from "path";
const storage = multer.memoryStorage();
const upload = multer({ storage });
const parser = new DataParser();
export const formatImage = (file) => {
const fileExtension = path.extname(file.originalname).toString();
return parser.format(fileExtension, file.buffer).content;
};
export default upload;
controller/userController.js
import { formatImage } from "../middleware/multerMiddleware.js";
export const updateUser = async (req, res) => {
const newUser = { ...req.body };
delete newUser.password;
if (req.file) {
const file = formatImage(req.file);
const response = await cloudinary.v2.uploader.upload(file);
newUser.avatar = response.secure_url;
newUser.avatarPublicId = response.public_id;
}
const updatedUser = await User.findByIdAndUpdate(req.user.userId, newUser);
if (req.file && updatedUser.avatarPublicId) {
await cloudinary.v2.uploader.destroy(updatedUser.avatarPublicId);
}
res.status(StatusCodes.OK).json({ msg: "update user" });
};
- create loading component (import/export)
- check for loading in DashboardLayout page
components/Loading.jsx
const Loading = () => {
return <div className="loading"></div>;
};
export default Loading;
DashboardLayout.jsx
import { useNavigation } from "react-router-dom";
import { Loading } from "../components";
const DashboardLayout = ({ isDarkThemeEnabled }) => {
const navigation = useNavigation();
const isPageLoading = navigation.state === "loading";
return (
<Wrapper>
...
<div className="dashboard-page">
{isPageLoading ? <Loading /> : <Outlet context={{ user }} />}
</div>
...
</Wrapper>
);
};
React Query is a powerful library that simplifies data fetching, caching, and synchronization in React applications. It provides a declarative and intuitive way to manage remote data by abstracting away the complex logic of fetching and caching data from APIs. React Query offers features like automatic background data refetching, optimistic updates, pagination support, and more, making it easier to build performant and responsive applications that rely on fetching and manipulating data.
- in the client
npm i @tanstack/[email protected] @tanstack/[email protected]
App.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
},
},
});
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
- create components/ErrorElement
import { useRouteError } from "react-router-dom";
const Error = () => {
const error = useRouteError();
console.log(error);
return <h4>There was an error...</h4>;
};
export default ErrorElement;
Stats.jsx
export const loader = async () => {
const response = await customFetch.get("/jobs/stats");
return response.data;
};
App.jsx
{
path: 'stats',
element: <Stats />,
loader: statsLoader,
errorElement: <h4>There was an error...</h4>
},
{
path: 'stats',
element: <Stats />,
loader: statsLoader,
errorElement: <ErrorElement />,
},
- navigate to stats
Stats.jsx
import { ChartsContainer, StatsContainer } from "../components";
import customFetch from "../utils/customFetch";
import { useLoaderData } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
export const loader = async () => {
return null;
};
const Stats = () => {
const response = useQuery({
queryKey: ["stats"],
queryFn: () => customFetch.get("/jobs/stats"),
});
console.log(response);
if (response.isLoading) {
return <h1>Loading...</h1>;
}
return <h1>react query</h1>;
return (
<>
<StatsContainer defaultStats={defaultStats} />
{monthlyApplications?.length > 1 && (
<ChartsContainer data={monthlyApplications} />
)}
</>
);
};
export default Stats;
const data = useQuery({
queryKey: ["stats"],
queryFn: () => customFetch.get("/jobs/stats"),
});
const data = useQuery({ ... });: This line declares a constant variable named data and assigns it the result of the useQuery hook. The useQuery hook is provided by React Query and is used to perform data fetching.
queryKey: ['stats'],: The queryKey property is an array that serves as a unique identifier for the query. In this case, the query key is set to ['stats'], indicating that this query is fetching statistics related to jobs.
queryFn: () => customFetch.get('/jobs/stats'),: The queryFn property specifies the function that will be executed when the query is triggered. In this case, it uses an arrow function that calls customFetch.get('/jobs/stats'). The customFetch object is likely a custom wrapper around the fetch function or an external HTTP client library, used to make the actual API request to retrieve job statistics.In React Query, the queryFn property expects a function that returns a promise. The promise should resolve with the data you want to fetch and store in the query cache.
customFetch.get('/jobs/stats'): This line is making an HTTP GET request to the /jobs/stats endpoint, which is the API route that provides the job statistics data.
const statsQuery = {
queryKey: ["stats"],
queryFn: async () => {
const response = await customFetch.get("/jobs/stats");
return response.data;
},
};
export const loader = async () => {
return null;
};
const Stats = () => {
const { isLoading, isError, data } = useQuery(statsQuery);
if (isLoading) return <h4>Loading...</h4>;
if (isError) return <h4>Error...</h4>;
// after loading/error or ?.
const { defaultStats, monthlyApplications } = data;
return (
<>
<StatsContainer defaultStats={defaultStats} />
{monthlyApplications?.length > 1 && (
<ChartsContainer data={monthlyApplications} />
)}
</>
);
};
export default Stats;
App.jsx
{
path: 'stats',
element: <Stats />,
loader: statsLoader(queryClient),
errorElement: <ErrorElement />,
},
Stats.jsx
import { ChartsContainer, StatsContainer } from "../components";
import customFetch from "../utils/customFetch";
import { useQuery } from "@tanstack/react-query";
const statsQuery = {
queryKey: ["stats"],
queryFn: async () => {
const response = await customFetch.get("/jobs/statss");
return response.data;
},
};
export const loader = (queryClient) => async () => {
const data = await queryClient.ensureQueryData(statsQuery);
return data;
};
const Stats = () => {
const { data } = useQuery(statsQuery);
const { defaultStats, monthlyApplications } = data;
return (
<>
<StatsContainer defaultStats={defaultStats} />
{monthlyApplications?.length > 1 && (
<ChartsContainer data={monthlyApplications} />
)}
</>
);
};
export default Stats;
DashboardLayout.jsx
const userQuery = {
queryKey: ["user"],
queryFn: async () => {
const { data } = await customFetch("/users/current-user");
return data;
},
};
export const loader = (queryClient) => async () => {
try {
return await queryClient.ensureQueryData(userQuery);
} catch (error) {
return redirect("/");
}
};
const Dashboard = ({ prefersDarkMode, queryClient }) => {
const { user } = useQuery(userQuery)?.data;
};
Login.jsx
export const action =
(queryClient) =>
async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
await axios.post("/api/v1/auth/login", data);
queryClient.invalidateQueries();
toast.success("Login successful");
return redirect("/dashboard");
} catch (error) {
toast.error(error.response.data.msg);
return error;
}
};
DashboardLayout.jsx
const logoutUser = async () => {
navigate("/");
await customFetch.get("/auth/logout");
queryClient.invalidateQueries();
toast.success("Logging out...");
};
Profile.jsx
export const action =
(queryClient) =>
async ({ request }) => {
const formData = await request.formData();
const file = formData.get("avatar");
if (file && file.size > 500000) {
toast.error("Image size too large");
return null;
}
try {
await customFetch.patch("/users/update-user", formData);
queryClient.invalidateQueries(["user"]);
toast.success("Profile updated successfully");
return redirect("/dashboard");
} catch (error) {
toast.error(error?.response?.data?.msg);
return null;
}
};
AllJobs.jsx
import { toast } from "react-toastify";
import { JobsContainer, SearchContainer } from "../components";
import customFetch from "../utils/customFetch";
import { useLoaderData } from "react-router-dom";
import { useContext, createContext } from "react";
import { useQuery } from "@tanstack/react-query";
const AllJobsContext = createContext();
const allJobsQuery = (params) => {
const { search, jobStatus, jobType, sort, page } = params;
return {
queryKey: [
"jobs",
search ?? "",
jobStatus ?? "all",
jobType ?? "all",
sort ?? "newest",
page ?? 1,
],
queryFn: async () => {
const { data } = await customFetch.get("/jobs", {
params,
});
return data;
},
};
};
export const loader =
(queryClient) =>
async ({ request }) => {
const params = Object.fromEntries([
...new URL(request.url).searchParams.entries(),
]);
await queryClient.ensureQueryData(allJobsQuery(params));
return { searchValues: { ...params } };
};
const AllJobs = () => {
const { searchValues } = useLoaderData();
const { data } = useQuery(allJobsQuery(searchValues));
return (
<AllJobsContext.Provider value={{ data, searchValues }}>
<SearchContainer />
<JobsContainer />
</AllJobsContext.Provider>
);
};
export default AllJobs;
export const useAllJobsContext = () => useContext(AllJobsContext);
AddJob.jsx
export const action =
(queryClient) =>
async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
await customFetch.post("/jobs", data);
queryClient.invalidateQueries(["jobs"]);
toast.success("Job added successfully ");
return redirect("all-jobs");
} catch (error) {
toast.error(error?.response?.data?.msg);
return error;
}
};
EditJob.jsx
export const action =
(queryClient) =>
async ({ request, params }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
await customFetch.patch(`/jobs/${params.id}`, data);
queryClient.invalidateQueries(["jobs"]);
toast.success("Job edited successfully");
return redirect("/dashboard/all-jobs");
} catch (error) {
toast.error(error?.response?.data?.msg);
return error;
}
};
DeleteJob.jsx
export const action =
(queryClient) =>
async ({ params }) => {
try {
await customFetch.delete(`/jobs/${params.id}`);
queryClient.invalidateQueries(["jobs"]);
toast.success("Job deleted successfully");
} catch (error) {
toast.error(error?.response?.data?.msg);
}
return redirect("/dashboard/all-jobs");
};
import { FormRow, FormRowSelect, SubmitBtn } from "../components";
import Wrapper from "../assets/wrappers/DashboardFormPage";
import { useLoaderData, useParams } from "react-router-dom";
import { JOB_STATUS, JOB_TYPE } from "../../../utils/constants";
import { Form, redirect } from "react-router-dom";
import { toast } from "react-toastify";
import customFetch from "../utils/customFetch";
import { useQuery } from "@tanstack/react-query";
const singleJobQuery = (id) => {
return {
queryKey: ["job", id],
queryFn: async () => {
const { data } = await customFetch.get(`/jobs/${id}`);
return data;
},
};
};
export const loader =
(queryClient) =>
async ({ params }) => {
try {
await queryClient.ensureQueryData(singleJobQuery(params.id));
return params.id;
} catch (error) {
toast.error(error?.response?.data?.msg);
return redirect("/dashboard/all-jobs");
}
};
export const action =
(queryClient) =>
async ({ request, params }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
await customFetch.patch(`/jobs/${params.id}`, data);
queryClient.invalidateQueries(["jobs"]);
toast.success("Job edited successfully");
return redirect("/dashboard/all-jobs");
} catch (error) {
toast.error(error?.response?.data?.msg);
return error;
}
};
const EditJob = () => {
const id = useLoaderData();
const {
data: { job },
} = useQuery(singleJobQuery(id));
return (
<Wrapper>
<Form method="post" className="form">
<h4 className="form-title">edit job</h4>
<div className="form-center">
<FormRow type="text" name="position" defaultValue={job.position} />
<FormRow type="text" name="company" defaultValue={job.company} />
<FormRow
type="text"
name="jobLocation"
labelText="job location"
defaultValue={job.jobLocation}
/>
<FormRowSelect
name="jobStatus"
labelText="job status"
defaultValue={job.jobStatus}
list={Object.values(JOB_STATUS)}
/>
<FormRowSelect
name="jobType"
labelText="job type"
defaultValue={job.jobType}
list={Object.values(JOB_TYPE)}
/>
<SubmitBtn formBtn />
</div>
</Form>
</Wrapper>
);
};
export default EditJob;
DashboardLayout.jsx
const DashboardContext = createContext();
const DashboardLayout = ({ isDarkThemeEnabled }) => {
const [isAuthError, setIsAuthError] = useState(false);
const logoutUser = async () => {
await customFetch.get('/auth/logout');
toast.success('Logging out...');
navigate('/');
};
customFetch.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error?.response?.status === 401) {
setIsAuthError(true);
}
return Promise.reject(error);
}
);
useEffect(() => {
if (!isAuthError) return;
logoutUser();
}, [isAuthError]);
return (
...
)
};
npm install helmet express-mongo-sanitize express-rate-limit
Package: helmet Description: helmet is a security package for Express.js applications that helps protect them by setting various HTTP headers to enhance security, prevent common web vulnerabilities, and improve overall application security posture. Need: The package is needed to safeguard web applications from potential security threats, such as cross-site scripting (XSS) attacks, clickjacking, and other security exploits.
Package: express-mongo-sanitize Description: express-mongo-sanitize is a middleware for Express.js that sanitizes user-supplied data coming from request parameters, body, and query strings to prevent potential NoSQL injection attacks on MongoDB databases. Need: The package addresses the need to protect MongoDB databases from malicious attempts to manipulate data and helps ensure the integrity of data storage and retrieval.
Package: express-rate-limit Description: express-rate-limit is an Express.js middleware that helps control and limit the rate of incoming requests from a specific IP address or a set of IP addresses to protect the server from abuse, brute-force attacks, and potential denial-of-service (DoS) attacks. Need: This package is necessary to manage and regulate the number of requests made to the server within a given time frame, preventing excessive usage and improving the overall stability and performance of the application.
server.js
import helmet from "helmet";
import mongoSanitize from "express-mongo-sanitize";
app.use(helmet());
app.use(mongoSanitize());
routes/authRouter.js
import rateLimiter from "express-rate-limit";
const apiLimiter = rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 15,
message: { msg: "IP rate limit exceeded, retry in 15 minutes." },
});
router.post("/register", apiLimiter, validateRegisterInput, register);
router.post("/login", apiLimiter, validateLoginInput, login);