some content
);
};
const Wrapper = styled.div`
background-color: red;
h1 {
color: white;
}
.content {
background-color: blue;
color: yellow;
}
`;
export default Landing;
```
#### Landing Page
```jsx
import main from "../assets/images/main.svg";
import { Link } from "react-router-dom";
import logo from "../assets/images/logo.svg";
import styled from "styled-components";
const Landing = () => {
return (
{/* info */}
);
};
const StyledWrapper = styled.section`
nav {
width: var(--fluid-width);
max-width: var(--max-width);
margin: 0 auto;
height: var(--nav-height);
display: flex;
align-items: center;
}
.page {
min-height: calc(100vh - var(--nav-height));
display: grid;
align-items: center;
margin-top: -3rem;
}
h1 {
font-weight: 700;
span {
color: var(--primary-500);
}
margin-bottom: 1.5rem;
}
p {
line-height: 2;
color: var(--text-secondary-color);
margin-bottom: 1.5rem;
max-width: 35em;
}
.register-link {
margin-right: 1rem;
}
.main-img {
display: none;
}
.btn {
padding: 0.75rem 1rem;
}
@media (min-width: 992px) {
.page {
grid-template-columns: 1fr 400px;
column-gap: 3rem;
}
.main-img {
display: block;
}
}
`;
export default Landing;
```
#### Assets/Wrappers
- css optional
Landing.jsx
```jsx
import Wrapper from "../assets/wrappers/LandingPage";
```
#### Logo Component
- create src/components/Logo.jsx
- import logo and setup component
- in components setup index.js import/export (just like pages)
- replace in Landing
Logo.jsx
```jsx
import logo from "../assets/images/logo.svg";
const Logo = () => {
return ;
};
export default Logo;
```
#### Logo and Images
- logo built in Figma
- [Cool Images](https://undraw.co/)
#### Error Page
Error.jsx
```jsx
import { Link, useRouteError } from "react-router-dom";
import img from "../assets/images/not-found.svg";
import Wrapper from "../assets/wrappers/ErrorPage";
const Error = () => {
const error = useRouteError();
console.log(error);
if (error.status === 404) {
return (
);
}
return (
);
};
export default Error;
```
#### Error Page CSS (optional)
assets/wrappers/Error.js
```js
import styled from "styled-components";
const Wrapper = styled.main`
min-height: 100vh;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
img {
width: 90vw;
max-width: 600px;
display: block;
margin-bottom: 2rem;
margin-top: -3rem;
}
h3 {
margin-bottom: 0.5rem;
}
p {
line-height: 1.5;
margin-top: 0.5rem;
margin-bottom: 1rem;
color: var(--text-secondary-color);
}
a {
color: var(--primary-500);
text-transform: capitalize;
}
`;
export default Wrapper;
```
#### Register Page
Register.jsx
```jsx
import { Logo } from "../components";
import Wrapper from "../assets/wrappers/RegisterAndLoginPage";
import { Link } from "react-router-dom";
const Register = () => {
return (
I'm baby wayfarers hoodie next level taiyaki brooklyn cliche blue bottle single-origin coffee chia. Aesthetic post-ironic venmo, quinoa lo-fi tote bag adaptogen everyday carry meggings +1 brunch narwhal.
Register Login / Demo User
name
submit
Already a member? Login
); }; export default Register; ``` - required attribute In HTML, the "required" attribute is used to indicate that a form input field must be filled out before the form can be submitted. It is typically applied to input elements such as text fields, checkboxes, and radio buttons. When the "required" attribute is added to an input element, the browser will prevent form submission if the field is left empty, providing a validation message to prompt the user to enter the required information. - default value In React, the defaultValue prop is used to set the initial or default value of an input component. It is similar to the value attribute in HTML, but with a slightly different behavior. #### FormRow Component - create components/FormRow.jsx (export/import) FormRow.jsx ```jsx const FormRow = ({ type, name, labelText, defaultValue = "" }) => { return (
{labelText || name}
);
};
export default FormRow;
```
Register.jsx
```jsx
import { Logo, FormRow } from "../components";
import Wrapper from "../assets/wrappers/RegisterAndLoginPage";
import { Link } from "react-router-dom";
const Register = () => {
return (
submit
Already a member? Login
); }; export default Register; ``` #### Login Page Login Page ```jsx import { Logo, FormRow } from "../components"; import Wrapper from "../assets/wrappers/RegisterAndLoginPage"; import { Link } from "react-router-dom"; const Login = () => { return ( submit explore the appNot a member yet? Register
); }; export default Login; ``` #### Register and Login CSS (optional) assets/wrappers/RegisterAndLoginPage.js ```js import styled from "styled-components"; const Wrapper = styled.section` min-height: 100vh; display: grid; align-items: center; .logo { display: block; margin: 0 auto; margin-bottom: 1.38rem; } .form { max-width: 400px; border-top: 5px solid var(--primary-500); } h4 { text-align: center; margin-bottom: 1.38rem; } p { margin-top: 1rem; text-align: center; line-height: 1.5; } .btn { margin-top: 1rem; } .member-btn { color: var(--primary-500); letter-spacing: var(--letter-spacing); margin-left: 0.25rem; } `; export default Wrapper; ``` #### Dashboard Pages App.jsx ```jsx { path: 'dashboard', element: , children: [ { index: true, element: , }, { path: 'stats', element: }, { path: 'all-jobs', element: , }, { path: 'profile', element: , }, { path: 'admin', element: , }, ], }, ``` Dashboard.jsx ```jsx import { Outlet } from "react-router-dom"; const DashboardLayout = () => { return (
{links.map((link) => {
const { text, path, icon } = link;
return (
{icon}
{text}
);
})}
{links.map((link) => {
const { text, path, icon } = link;
// admin user
return (
{icon}
{text}
);
})}
);
};
export default NavLinks;
```
#### Big Sidebar
```jsx
import NavLinks from "./NavLinks";
import Logo from "../components/Logo";
import Wrapper from "../assets/wrappers/BigSidebar";
import { useDashboardContext } from "../pages/DashboardLayout";
const BigSidebar = () => {
const { showSidebar } = useDashboardContext();
return (
{links.map((link) => {
const { text, path, icon } = link;
// admin user
return (
{icon}
{text}
);
})}
);
};
export default NavLinks;
```
#### BigSidebar CSS (optional)
assets/wrappers/BigSidebar.js
```js
import styled from "styled-components";
const Wrapper = styled.aside`
display: none;
@media (min-width: 992px) {
display: block;
box-shadow: 1px 0px 0px 0px rgba(0, 0, 0, 0.1);
.sidebar-container {
background: var(--background-secondary-color);
min-height: 100vh;
height: 100%;
width: 250px;
margin-left: -250px;
transition: margin-left 0.3s ease-in-out;
}
.content {
position: sticky;
top: 0;
}
.show-sidebar {
margin-left: 0;
}
header {
height: 6rem;
display: flex;
align-items: center;
padding-left: 2.5rem;
}
.nav-links {
padding-top: 2rem;
display: flex;
flex-direction: column;
}
.nav-link {
display: flex;
align-items: center;
color: var(--text-secondary-color);
padding: 1rem 0;
padding-left: 2.5rem;
text-transform: capitalize;
transition: padding-left 0.3s ease-in-out;
}
.nav-link:hover {
padding-left: 3rem;
color: var(--primary-500);
transition: var(--transition);
}
.icon {
font-size: 1.5rem;
margin-right: 1rem;
display: grid;
place-items: center;
}
.active {
color: var(--primary-500);
}
}
`;
export default Wrapper;
```
#### LogoutContainer
components/LogoutContainer.jsx
```jsx
import { FaUserCircle, FaCaretDown } from "react-icons/fa";
import Wrapper from "../assets/wrappers/LogoutContainer";
import { useState } from "react";
import { useDashboardContext } from "../pages/DashboardLayout";
const LogoutContainer = () => {
const [showLogout, setShowLogout] = useState(false);
const { user, logoutUser } = useDashboardContext();
return (
setShowLogout(!showLogout)}
>
{user.avatar ? (
) : (
)}
{user?.name}
logout
);
};
export default LogoutContainer;
```
#### LogoutContainer CSS (optional)
assets/wrappers/LogoutContainer.js
```js
import styled from "styled-components";
const Wrapper = styled.div`
position: relative;
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0 0.5rem;
}
.img {
width: 25px;
height: 25px;
border-radius: 50%;
}
.dropdown {
position: absolute;
top: 45px;
left: 0;
width: 100%;
box-shadow: var(--shadow-2);
text-align: center;
visibility: hidden;
border-radius: var(--border-radius);
background: var(--primary-500);
}
.show-dropdown {
visibility: visible;
}
.dropdown-btn {
border-radius: var(--border-radius);
padding: 0.5rem;
background: transparent;
border-color: transparent;
color: var(--white);
letter-spacing: var(--letter-spacing);
text-transform: capitalize;
cursor: pointer;
width: 100%;
height: 100%;
}
`;
export default Wrapper;
```
#### ThemeToggle
components/ThemeToggle.jsx
```jsx
import { BsFillSunFill, BsFillMoonFill } from "react-icons/bs";
import Wrapper from "../assets/wrappers/ThemeToggle";
import { useDashboardContext } from "../pages/DashboardLayout";
const ThemeToggle = () => {
const { isDarkTheme, toggleDarkTheme } = useDashboardContext();
return (
{isDarkTheme ? (
) : (
)}
);
};
export default ThemeToggle;
```
Navbar.jsx
```jsx
Not a member yet? Register
); }; export default Login; ``` #### Access Action Data (optional) ```js import { useActionData } from "react-router-dom"; export const action = async ({ request }) => { const formData = await request.formData(); const data = Object.fromEntries(formData); const errors = { msg: "" }; if (data.password.length < 3) { errors.msg = "password too short"; return errors; } try { await customFetch.post("/auth/login", data); toast.success("Login successful"); return redirect("/dashboard"); } catch (error) { // toast.error(error?.response?.data?.msg); errors.msg = error.response.data.msg; return errors; } }; const Login = () => { const errors = useActionData(); return ( ... {errors &&{errors.msg}
} ... ); }; export default Login; ``` #### Get Current User Each route can define a "loader" function to provide data to the route element before it renders. - must return a value DashboardLayout.jsx ```jsx import { Outlet, redirect, useLoaderData } from 'react-router-dom'; import customFetch from '../utils/customFetch'; export const loader = async () => { try { const { data } = await customFetch('/users/current-user'); return data; } catch (error) { return redirect('/'); } }; const DashboardLayout = ({ isDarkThemeEnabled }) => { const { user } = useLoaderData(); return ( ...
{isSubmitting ? "submitting..." : "submit"}
);
};
export default AddJob;
```
#### Select Input
```js
job status
{Object.values(JOB_TYPE).map((itemValue) => {
return (
{itemValue}
);
})}
```
#### FormRowSelect Component
components/FormRowSelect.jsx
```js
const FormRowSelect = ({ name, labelText, list, defaultValue = "" }) => {
return (
{labelText || name}
{list.map((itemValue) => {
return (
{itemValue}
);
})}
);
};
export default FormRowSelect;
```
pages/AddJob.jsx
```js
```
#### Create Job
AddJob.jsx
```js
export const action = async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
await customFetch.post("/jobs", data);
toast.success("Job added successfully");
return null;
} catch (error) {
toast.error(error?.response?.data?.msg);
return error;
}
};
```
#### Pending Class and Redirect
wrappers/BigSidebar.js
```css
.pending {
background: var(--background-color);
}
```
AddJob.jsx
```js
export const action = async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
await customFetch.post("/jobs", data);
toast.success("Job added successfully");
return redirect("all-jobs");
} catch (error) {
toast.error(error?.response?.data?.msg);
return error;
}
};
```
#### Add Job - CSS(optional)
wrappers/DashboardFormPage.js
```js
import styled from "styled-components";
const Wrapper = styled.section`
border-radius: var(--border-radius);
width: 100%;
background: var(--background-secondary-color);
padding: 3rem 2rem 4rem;
box-shadow: var(--shadow-2);
.form-title {
margin-bottom: 2rem;
}
.form {
margin: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
max-width: 100%;
width: 100%;
}
.form-row {
margin-bottom: 0;
}
.form-center {
display: grid;
row-gap: 1rem;
}
.form-btn {
align-self: end;
margin-top: 1rem;
display: grid;
place-items: center;
}
@media (min-width: 992px) {
.form-center {
grid-template-columns: 1fr 1fr;
align-items: center;
column-gap: 1rem;
}
}
@media (min-width: 1120px) {
.form-center {
grid-template-columns: 1fr 1fr 1fr;
}
}
`;
export default Wrapper;
```
#### All Jobs - Structure
- create JobsContainer and SearchContainer (export)
- handle loader in App.jsx
```js
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";
export const loader = async ({ request }) => {
try {
const { data } = await customFetch.get("/jobs");
return {
data,
};
} catch (error) {
toast.error(error?.response?.data?.msg);
return error;
}
};
const AllJobs = () => {
const { data } = useLoaderData();
return (
<>
);
};
export default AllJobs;
```
#### Setup All Jobs Context
```js
const AllJobsContext = createContext();
const AllJobs = () => {
const { data } = useLoaderData();
return (
);
};
export const useAllJobsContext = () => useContext(AllJobsContext);
```
#### Render Jobs
- create Job.jsx
JobsContainer.jsx
```js
import Job from "./Job";
import Wrapper from "../assets/wrappers/JobsContainer";
import { useAllJobsContext } from "../pages/AllJobs";
const JobsContainer = () => {
const { data } = useAllJobsContext();
const { jobs } = data;
if (jobs.length === 0) {
return (
);
}
return (
{jobs.map((job) => {
return ;
})}
);
};
export default JobsContainer;
```
#### JobsContainer - CSS (optional)
wrappers/JobsContainer.js
```js
import styled from "styled-components";
const Wrapper = styled.section`
margin-top: 4rem;
h2 {
text-transform: none;
}
& > h5 {
font-weight: 700;
margin-bottom: 1.5rem;
}
.jobs {
display: grid;
grid-template-columns: 1fr;
row-gap: 2rem;
}
@media (min-width: 1120px) {
.jobs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
}
`;
export default Wrapper;
```
#### Dayjs
```sh
npm i [email protected]
```
[Dayjs Docs](https://day.js.org/docs/en/installation/installation)
#### Job Component
- create JobInfo component
```js
import { FaLocationArrow, FaBriefcase, FaCalendarAlt } from "react-icons/fa";
import { Link } from "react-router-dom";
import Wrapper from "../assets/wrappers/Job";
import JobInfo from "./JobInfo";
import { Form } from "react-router-dom";
import day from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
day.extend(advancedFormat);
const Job = ({
_id,
position,
company,
jobLocation,
jobType,
createdAt,
jobStatus,
}) => {
const date = day(createdAt).format("MMM Do, YYYY");
return (
{company.charAt(0)}
} text={jobLocation} />
} text={date} />
} text={jobType} />
Edit
Delete
{jobStatus}
{isSubmitting ? "submitting..." : "submit"}
);
};
export default EditJob;
```
#### Delete Job
Job.jsx
```js
Delete
```
pages/DeleteJob.jsx
```js
import { redirect } from "react-router-dom";
import customFetch from "../utils/customFetch";
import { toast } from "react-toastify";
export async function action({ params }) {
try {
await customFetch.delete(`/jobs/${params.id}`);
toast.success("Job deleted successfully");
} catch (error) {
toast.error(error.response.data.msg);
}
return redirect("/dashboard/all-jobs");
}
```
App.jsx
```js
import { action as deleteJobAction } from './pages/DeleteJob';
{ path: 'delete-job/:id', action: deleteJobAction },
```
#### Admin Page
pages/Admin.jsx
```js
import { FaSuitcaseRolling, FaCalendarCheck } from "react-icons/fa";
import { useLoaderData, redirect } from "react-router-dom";
import customFetch from "../utils/customFetch";
import Wrapper from "../assets/wrappers/StatsContainer";
import { toast } from "react-toastify";
export const loader = async () => {
try {
const response = await customFetch.get("/users/admin/app-stats");
return response.data;
} catch (error) {
toast.error("You are not authorized to view this page");
return redirect("/dashboard");
}
};
const Admin = () => {
const { users, jobs } = useLoaderData();
return (
);
};
export default Admin;
```
App.jsx
```js
import { loader as adminLoader } from './pages/Admin';
{
path: 'admin',
element: ,
loader: adminLoader,
},
```
NavLinks.jsx
```js
{
links.map((link) => {
const { text, path, icon } = link;
const { role } = user;
if (role !== "admin" && path === "admin") return;
});
}
```
#### StatItem Component
- create StatItem.jsx
- import/export
StatItem.jsx
```js
import Wrapper from "../assets/wrappers/StatItem";
const StatItem = ({ count, title, icon, color, bcg }) => {
return (
{count}
{icon}
);
};
export default StatItem;
```
Admin.jsx
```js
import { StatItem } from "../components";
const Admin = () => {
const { users, jobs } = useLoaderData();
return (
}
/>
}
/>
);
};
export default Admin;
```
#### Admin - CSS (optional)
wrappers/StatsContainer.js
```js
import styled from "styled-components";
const Wrapper = styled.section`
display: grid;
row-gap: 2rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
}
@media (min-width: 1120px) {
grid-template-columns: 1fr 1fr 1fr;
column-gap: 1rem;
}
`;
export default Wrapper;
```
wrappers/StatItem.js
```js
import styled from "styled-components";
const Wrapper = styled.article`
padding: 2rem;
background: var(--background-secondary-color);
border-radius: var(--border-radius);
border-bottom: 5px solid ${(props) => props.color};
header {
display: flex;
align-items: center;
justify-content: space-between;
}
.count {
display: block;
font-weight: 700;
font-size: 50px;
color: ${(props) => props.color};
line-height: 2;
}
.title {
margin: 0;
text-transform: capitalize;
letter-spacing: var(--letter-spacing);
text-align: left;
margin-top: 0.5rem;
font-size: 1.25rem;
}
.icon {
width: 70px;
height: 60px;
background: ${(props) => props.bcg};
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
svg {
font-size: 2rem;
color: ${(props) => props.color};
}
}
`;
export default Wrapper;
```
#### Avatar Image
- get two images from pexels
[pexels](https://www.pexels.com/search/person/)
#### Setup Public Folder
server.js
```js
import { dirname } from "path";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.resolve(__dirname, "./public")));
```
- http://localhost:5100/imageName
#### Profile Page - Initial Setup
- remove jobs,users from DB
- add avatar property in the user model
models/UserModel.js
```js
const UserSchema = new mongoose.Schema({
avatar: String,
avatarPublicId: String,
});
```
#### Profile Page - Structure
pages/Profile.jsx
```js
import { FormRow } from "../components";
import Wrapper from "../assets/wrappers/DashboardFormPage";
import { useOutletContext } from "react-router-dom";
import { useNavigation, Form } from "react-router-dom";
import customFetch from "../utils/customFetch";
import { toast } from "react-toastify";
const Profile = () => {
const { user } = useOutletContext();
const { name, lastName, email, location } = user;
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
Select an image file (max 0.5 MB):
{isSubmitting ? "submitting..." : "save changes"}
{/* search position */}
Reset Search Values
{/* TEMP!!!! */}
);
};
export default SearchContainer;
```
#### All Jobs Loader
AllJobs.jsx
```js
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";
const AllJobsContext = createContext();
export const loader = async ({ request }) => {
try {
const params = Object.fromEntries([
...new URL(request.url).searchParams.entries(),
]);
const { data } = await customFetch.get("/jobs", {
params,
});
return {
data,
searchValues: { ...params },
};
} catch (error) {
toast.error(error.response.data.msg);
return error;
}
};
const AllJobs = () => {
const { data, searchValues } = useLoaderData();
return (
);
};
export default AllJobs;
export const useAllJobsContext = () => useContext(AllJobsContext);
```
```js
const params = Object.fromEntries([
...new URL(request.url).searchParams.entries(),
]);
```
new URL(request.url): This creates a new URL object by passing the request.url to the URL constructor. The URL object provides various methods and properties to work with URLs.
.searchParams: The searchParams property of the URL object gives you access to the query parameters in the URL. It is an instance of the URLSearchParams class, which provides methods to manipulate and access the parameters.
.entries(): The entries() method of searchParams returns an iterator containing arrays of key-value pairs for each query parameter. Each array contains two elements: the parameter name and its corresponding value.
([...new URL(request.url).searchParams.entries()]): The spread operator ... is used to convert the iterator obtained from searchParams.entries() into an array. This allows us to pass the array to the Object.fromEntries() method.
Object.fromEntries(): This static method creates an object from an array of key-value pairs. It takes an iterable (in this case, the array of parameter key-value pairs) and returns a new object where the keys and values are derived from the iterable.
Putting it all together, the code retrieves the URL from the request.url property, extracts the search parameters using the searchParams property, converts them into an array of key-value pairs using entries(), and finally uses Object.fromEntries() to create an object with the parameter names as keys and their corresponding values. The resulting object, params, contains all the search parameters from the URL.
#### Submit Form Programmatically
- setup default values from the context
- remove SubmitBtn
- add onChange to FormRow, FormRowSelect and all inputs
SearchContainer.js
```js
import { FormRow, FormRowSelect } from ".";
import Wrapper from "../assets/wrappers/DashboardFormPage";
import { Form, useSubmit, Link } from "react-router-dom";
import { JOB_TYPE, JOB_STATUS, JOB_SORT_BY } from "../../../utils/constants";
import { useAllJobsContext } from "../pages/AllJobs";
const SearchContainer = () => {
const { searchValues } = useAllJobsContext();
const { search, jobStatus, jobType, sort } = searchValues;
const submit = useSubmit();
return (
{/* search position */}
{
submit(e.currentTarget.form);
}}
/>
{
submit(e.currentTarget.form);
}}
/>
{
submit(e.currentTarget.form);
}}
/>
{
submit(e.currentTarget.form);
}}
/>
Reset Search Values
);
};
export default SearchContainer;
```
#### Debounce
[JS Nuggets - Debounce](https://youtu.be/tYx6pXdvt1s)
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.
```js
const debounce = (onChange) => {
let timeout;
return (e) => {
const form = e.currentTarget.form;
clearTimeout(timeout);
timeout = setTimeout(() => {
onChange(form);
}, 2000);
};
};
{
submit(form);
})}
/>;
```
#### Pagination - Setup
- create PageBtnContainer
JobsContainer.jsx
```js
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 (
);
}
return (
{jobs.map((job) => {
return ;
})}
{numOfPages > 1 && }
);
};
export default JobsContainer;
```
#### Basic PageBtnContainer
```js
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 (
{
let prevPage = currentPage - 1;
if (prevPage < 1) prevPage = numOfPages;
handlePageChange(prevPage);
}}
>
prev
{pages.map((pageNumber) => (
handlePageChange(pageNumber)}
>
{pageNumber}
))}
{
let nextPage = currentPage + 1;
if (nextPage > numOfPages) nextPage = 1;
handlePageChange(nextPage);
}}
>
next
);
};
export default PageBtnContainer;
```
#### Complex - PageBtnContainer
```js
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 (
handlePageChange(pageNumber)}
>
{pageNumber}
);
};
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(
....
);
}
// 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(
....
);
}
// Add the last page button
pageButtons.push(
addPageButton({
pageNumber: numOfPages,
activeClass: currentPage === numOfPages,
})
);
return pageButtons;
};
return (
{
let prevPage = currentPage - 1;
if (prevPage < 1) prevPage = numOfPages;
handlePageChange(prevPage);
}}
>
prev
{renderPageButtons()}
{
let nextPage = currentPage + 1;
if (nextPage > numOfPages) nextPage = 1;
handlePageChange(nextPage);
}}
>
next
);
};
export default PageBtnContainer;
```
#### PageBtnContainer CSS (optional)
wrappers/PageBtnContainer.js
```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;
```
#### Local Build
- remove default values from inputs in Register and Login
- navigate to client and build front-end
```sh
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
```js
app.get("*", (req, res) => {
res.sendFile(path.resolve(__dirname, "./public", "index.html"));
});
```
#### Deploy On Render
[Render](https://render.com/)
- sign up of for account
- create git repository
#### Build Front-End on Render
- add script
- change path
package.json
```js
"scripts": {
"setup-production-app": "npm i && cd client && npm i && npm run build",
},
```
server.js
```js
app.use(express.static(path.resolve(__dirname, "./client/dist")));
app.get("*", (req, res) => {
res.sendFile(path.resolve(__dirname, "./client/dist", "index.html"));
});
```
#### Test Locally
- 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"
#### Test in Production
- change build command on render
```sh
npm run setup-production-app
```
- push up to github
#### Upload Image As Buffer
- remove public folder
```sh
npm i [email protected]
```
middleware/multerMiddleware.js
```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
```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" });
};
```
#### Setup Global Loading
- create loading component (import/export)
- check for loading in DashboardLayout page
components/Loading.jsx
```js
const Loading = () => {
return ;
};
export default Loading;
```
DashboardLayout.jsx
```js
import { useNavigation } from "react-router-dom";
import { Loading } from "../components";
const DashboardLayout = ({ isDarkThemeEnabled }) => {
const navigation = useNavigation();
const isPageLoading = navigation.state === "loading";
return (
...
{isPageLoading ? : }
...
);
};
```
#### React Query
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.
[React Query Docs](https://tanstack.com/query/v4/docs/react/overview)
- in the client
```sh
npm i @tanstack/[email protected] @tanstack/[email protected]
```
App.jsx
```js
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 (
);
};
```
#### Page Error Element
- create components/ErrorElement
```js
import { useRouteError } from "react-router-dom";
const Error = () => {
const error = useRouteError();
console.log(error);
return ;
};
export default ErrorElement;
```
Stats.jsx
```js
export const loader = async () => {
const response = await customFetch.get("/jobs/stats");
return response.data;
};
```
App.jsx
```js
{
path: 'stats',
element: ,
loader: statsLoader,
errorElement:
},
```
```js
{
path: 'stats',
element: ,
loader: statsLoader,
errorElement: ,
},
```
#### First Query
- navigate to stats
Stats.jsx
```js
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 ;
}
return ;
return (
<>
{monthlyApplications?.length > 1 && (
)}
);
};
export default Stats;
```
```js
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.
#### Get Stats with React Query
```js
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 ;
if (isError) return ;
// after loading/error or ?.
const { defaultStats, monthlyApplications } = data;
return (
<>
{monthlyApplications?.length > 1 && (
)}
);
};
export default Stats;
```
#### React Query in Stats Loader
App.jsx
```js
{
path: 'stats',
element: ,
loader: statsLoader(queryClient),
errorElement: ,
},
```
Stats.jsx
```js
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 (
<>
{monthlyApplications?.length > 1 && (
)}
);
};
export default Stats;
```
#### React Query for Current User
DashboardLayout.jsx
```js
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;
};
```
#### Invalidate Queries
Login.jsx
```js
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
```js
const logoutUser = async () => {
navigate("/");
await customFetch.get("/auth/logout");
queryClient.invalidateQueries();
toast.success("Logging out...");
};
```
Profile.jsx
```js
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;
}
};
```
#### All Jobs Query
AllJobs.jsx
```js
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 (
);
};
export default AllJobs;
export const useAllJobsContext = () => useContext(AllJobsContext);
```
#### Invalidate Jobs
AddJob.jsx
```js
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
```js
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
```js
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");
};
```
#### Edit Job Loader
```js
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 (