Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/api/comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import instance from "../utils/axios";

export function fetchCommentsByPostId(postId) {
return instance.get(`/comments?postId=${postId}`);
}
37 changes: 37 additions & 0 deletions app/api/posts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import instance from "../utils/axios";
const ITEMS_PER_PAGE = 4;

export function fetchFilteredPostsAndTotalCount(userId, currentPage, query) {
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
const params = {
params: {
userId: userId,
_start: offset,
_limit: ITEMS_PER_PAGE,
q: query,
},
};

const paginatedPostsPromise = instance.get("/posts", params);
const allPostsPromise = instance.get("/posts", {
params: { userId: userId, q: query },
});

return Promise.all([paginatedPostsPromise, allPostsPromise]);
}

export function fetchPostById(id) {
return instance.get(`/posts/${id}`);
}

export function createPost(data) {
return instance.post("/posts", data);
}

export function editPost(id, data) {
return instance.put(`/posts/${id}`, data);
}

export function deletePost(id) {
return instance.delete(`/posts/${id}`);
}
21 changes: 21 additions & 0 deletions app/api/users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import instance from "../utils/axios";

export function fetchUsers(query) {
return instance.get(`/users?q=${query}`);
}

export function createUser(data) {
return instance.post("/users", data);
}

export function fetchUserById(id) {
return instance.get(`/users/${id}`);
}

export function editUser(id, data) {
return instance.put(`/users/${id}`, data);
}

export function deleteUser(id) {
return instance.delete(`/users/${id}`);
}
8 changes: 8 additions & 0 deletions app/components/fonts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Inter } from "next/font/google";
import { Lusitana } from "next/font/google";

export const inter = Inter({ subsets: ["latin"] });
export const lusitana = Lusitana({
subsets: ["latin"],
weight: ["700", "400"],
});
13 changes: 13 additions & 0 deletions app/components/logo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { GlobeAltIcon } from "@heroicons/react/24/outline";
import { lusitana } from "./fonts";

export default function AcmeLogo() {
return (
<div
className={`${lusitana.className} flex flex-row items-center leading-none text-white`}
>
<GlobeAltIcon className="h-12 w-12 rotate-[15deg]" />
<p className="text-[44px]">Demo</p>
</div>
);
}
26 changes: 26 additions & 0 deletions app/components/shared/Breadcrumbs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { clsx } from "clsx";
import Link from "next/link";
import { lusitana } from "../fonts";

export default async function Breadcrumbs({ breadcrumbs }) {
return (
<nav aria-label="Breadcrumb" className="mb-6 block">
<ol className={clsx(lusitana.className, "flex text-xl md:text-2xl")}>
{breadcrumbs.map((breadcrumb, index) => (
<li
key={breadcrumb.href}
aria-current={breadcrumb.active}
className={clsx(
breadcrumb.active ? "text-gray-900" : "text-gray-500"
)}
>
<Link href={breadcrumb.href}>{breadcrumb.label}</Link>
{index < breadcrumbs.length - 1 ? (
<span className="mx-3 inline-block">/</span>
) : null}
</li>
))}
</ol>
</nav>
);
}
15 changes: 15 additions & 0 deletions app/components/shared/Button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import clsx from "clsx";

export function Button({ children, className, ...rest }) {
return (
<button
{...rest}
className={clsx(
"flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
className
)}
>
{children}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
"use client";

import { useEffect } from "react";

export default function Error({ error, reset }) {
import {
SOMETHING_WENT_WRONG,
TRY_AGAIN,
} from "../../utils/constants/genericConstants";
export default function CustomError({ error, reset }) {
useEffect(() => {
console.error(error);
}, [error]);

return (
<main className="flex h-full flex-col items-center justify-center">
<h2 className="text-center">Something went wrong!</h2>
<h2 className="text-center">{SOMETHING_WENT_WRONG}</h2>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
onClick={() => reset()}
onClick={reset}
>
Try again
{TRY_AGAIN}
</button>
</main>
);
Expand Down
39 changes: 39 additions & 0 deletions app/components/shared/NavLinks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { UserGroupIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { USERS as USER_URL } from "../../utils/constants/urlConstants";
import { USERS } from "../../users/constants";

const links = [{ name: USERS, href: USER_URL, icon: UserGroupIcon }];

export default function NavLinks() {
const pathname = usePathname();

return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
const isCurrentPage = `/${pathname.split("/")?.[1]}` === link.href;

return (
<Link
key={link.name}
href={link.href}
className={clsx(
"flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3",
{
"bg-sky-100 text-blue-600": isCurrentPage,
}
)}
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}
103 changes: 103 additions & 0 deletions app/components/shared/Pagination.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { calculatePosition, generatePagination } from "../../utils";

export default function Pagination({ totalPages }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get("page")) || 1;

const createPageURL = (pageNumber) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
};

const allPages = generatePagination(currentPage, totalPages);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compute the position here

return (
<>
<div className="inline-flex">
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>

<div className="flex -space-x-px">
{allPages.map((page, index) => {
const position = calculatePosition(index, allPages.length, page);

return (
<PaginationNumber
key={page}
href={createPageURL(page)}
page={page}
position={position}
isActive={currentPage === page}
/>
);
})}
</div>

<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= totalPages}
/>
</div>
</>
);
}

function PaginationNumber({ page, href, isActive, position }) {
const className = clsx(
"flex h-10 w-10 items-center justify-center text-sm border",
{
"rounded-l-md": position === "first" || position === "single",
"rounded-r-md": position === "last" || position === "single",
"z-10 bg-blue-600 border-blue-600 text-white": isActive,
"hover:bg-gray-100": !isActive && position !== "middle",
"text-gray-300": position === "middle",
}
);

return isActive || position === "middle" ? (
<div className={className}>{page}</div>
) : (
<Link href={href} className={className}>
{page}
</Link>
);
}

function PaginationArrow({ href, direction, isDisabled }) {
const className = clsx(
"flex h-10 w-10 items-center justify-center rounded-md border",
{
"pointer-events-none text-gray-300": isDisabled,
"hover:bg-gray-100": !isDisabled,
"mr-2 md:mr-4": direction === "left",
"ml-2 md:ml-4": direction === "right",
}
);

const icon =
direction === "left" ? (
<ArrowLeftIcon className="w-4" />
) : (
<ArrowRightIcon className="w-4" />
);

return isDisabled ? (
<div className={className}>{icon}</div>
) : (
<Link className={className} href={href}>
{icon}
</Link>
);
}
37 changes: 37 additions & 0 deletions app/components/shared/Search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
import { PAGE, QUERY, SEARCH } from "../../utils/constants/genericConstants";

export default function Search({ placeholder }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();

const handleSearch = useDebouncedCallback((e) => {
const term = e.target.value;
const params = new URLSearchParams(searchParams);
params.set(PAGE, "1");
if (term) params.set(QUERY, term);
else params.delete(QUERY);

replace(`${pathname}?${params.toString()}`);
}, 300);

return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
{SEARCH}
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={handleSearch}
defaultValue={searchParams.get("query")?.toString()}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
28 changes: 28 additions & 0 deletions app/components/shared/SideBar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Link from "next/link";
import { PowerIcon } from "@heroicons/react/24/outline";
import AcmeLogo from "../logo";
import NavLinks from "./NavLinks";
import { HOME } from "../../utils/constants/urlConstants";

export default function SideBar() {
return (
<div className="flex h-full flex-col px-3 py-4 md:px-2">
<Link
className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
href={HOME}
>
<div className="w-32 text-white md:w-40">
<AcmeLogo />
</div>
</Link>
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
<NavLinks />
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
<button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
<PowerIcon className="w-6" />
<div className="hidden md:block">Sign Out</div>
</button>
</div>
</div>
);
}
Loading