diff --git a/.gitignore b/.gitignore index 8f322f0d..17615d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +.env # testing /coverage diff --git a/apis/boards.tsx b/apis/boards.tsx new file mode 100644 index 00000000..5aae84a8 --- /dev/null +++ b/apis/boards.tsx @@ -0,0 +1,36 @@ +import api from "./index"; + +export interface BoardItem { + updatedAt: string; + createdAt: string; + likeCount: number; + writer: { + nickname: string; + id: number; + }; + image: string; + content: string; + title: string; + id: number; +} + +export interface Boards { + totalCount?: 0; + list: BoardItem[]; +} + +interface Params { + page: number; + pageSize: number; + orderBy: string; + keyword: string; +} + +export async function getBoards(params: Params): Promise { + const { page, pageSize, orderBy, keyword } = params; + const response = await api.get("/articles", { + params: { page, pageSize, orderBy, keyword }, + }); + + return response.data; +} diff --git a/apis/index.tsx b/apis/index.tsx new file mode 100644 index 00000000..5dc79605 --- /dev/null +++ b/apis/index.tsx @@ -0,0 +1,12 @@ +import axios from "axios"; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL; + +const api = axios.create({ + baseURL: BASE_URL, + headers: { + "Content-Type": "application/json", + }, +}); + +export default api; diff --git a/app/boards/getBoardsData.tsx b/app/boards/getBoardsData.tsx new file mode 100644 index 00000000..5ca7e684 --- /dev/null +++ b/app/boards/getBoardsData.tsx @@ -0,0 +1,23 @@ +import { getBoards } from "@/apis/boards"; + +export default async function getBoardsData() { + const allData = await getBoards({ + page: 1, + pageSize: 10, + orderBy: "recent", + keyword: "", + }); + + const initialAllData = allData.list; + + const bestData = await getBoards({ + page: 1, + pageSize: 3, + orderBy: "like", + keyword: "", + }); + + const initialBestData = bestData?.list ?? []; + + return { initialAllData, initialBestData }; +} diff --git a/app/boards/page.tsx b/app/boards/page.tsx new file mode 100644 index 00000000..04b1fa95 --- /dev/null +++ b/app/boards/page.tsx @@ -0,0 +1,23 @@ +import Best from "@/components/BestBoards/Best"; +import All from "@/components/AllBoards/All"; +import getBoardsData from "./getBoardsData"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "판다마켓 | 자유게시판", +}; + +export const revalidate = 60; + +export default async function Boards() { + const { initialAllData, initialBestData } = await getBoardsData(); + + return ( +
+
+ + +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 00000000..9c28f6db --- /dev/null +++ b/app/globals.css @@ -0,0 +1,83 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + * { + box-sizing: border-box; + text-decoration: none; + list-style: none; + } + + body { + font-family: "Pretendard", sans-serif; + } + + body, + p, + h2 { + margin: 0; + } + + ul, + li { + margin: 0; + padding: 0; + } + + button, + input, + textarea { + border: none; + } +} + +@layer utilities { + .line-break { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + text-align: left; + overflow: hidden; + } +} + +@font-face { + font-family: "ROKAF Sans"; + src: url("/font/ROKAF.ttf"); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: "Pretendard"; + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff2") + format("woff2"); + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff") + format("woff"); + font-display: swap; + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: "Pretendard"; + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-SemiBold.woff2") + format("woff2"); + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-SemiBold.woff") + format("woff"); + font-display: swap; + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: "Pretendard"; + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Bold.woff2") + format("woff2"); + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Bold.woff") + format("woff"); + font-display: swap; + font-weight: 700; + font-style: normal; +} diff --git a/app/items/page.tsx b/app/items/page.tsx new file mode 100644 index 00000000..db00befd --- /dev/null +++ b/app/items/page.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "판다마켓 | 중고마켓", +}; + +export default function Home() { + return <>items page; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..9f0fb709 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import Header from "@/components/common/Header/Header"; +import "./globals.css"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "판다마켓", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+ {children} + + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..55821ab1 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import Image from "next/image"; +import panda from "@/public/icons/panda.svg"; + +export const metadata: Metadata = { + title: "판다마켓 | not found", +}; + +export default function NotFound() { + return ( +
+ panda +

404 | NOT FOUND

+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 00000000..f38d8f04 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return <>home page; +} diff --git a/components/AllBoards/All.tsx b/components/AllBoards/All.tsx new file mode 100644 index 00000000..c552b421 --- /dev/null +++ b/components/AllBoards/All.tsx @@ -0,0 +1,54 @@ +"use client"; + +import AllItem from "./AllItem"; +import Dropdown from "../common/Dropdown/Dropdown"; +import Search from "../Search/Search"; +import Pagination from "../Pagination/Pagination"; +import useParams from "@/hooks/useParams"; +import useAllData from "./useAllData"; +import { BoardItem } from "@/apis/boards"; + +const FilterList = ["recent", "like"]; +const PAGE_SIZE = 10; + +interface AllProps { + initialData: BoardItem[]; +} + +export default function All({ initialData }: AllProps) { + const { page, orderBy, keyword, handleParamsUpdate } = useParams(); + const { all, totalBoards } = useAllData({ initialData, PAGE_SIZE }); + + return ( +
+
+

게시글

+ +
+
+
+ + handleParamsUpdate({ orderBy: filter })} + /> +
+
+ {all.map((item) => ( + + ))} +
+ {!keyword && ( + + )} +
+
+ ); +} diff --git a/components/AllBoards/AllItem.tsx b/components/AllBoards/AllItem.tsx new file mode 100644 index 00000000..1c48ea20 --- /dev/null +++ b/components/AllBoards/AllItem.tsx @@ -0,0 +1,42 @@ +import Image from "next/image"; +import user from "@/public/icons/user.svg"; +import heart from "@/public/icons/emptyHeart.svg"; +import formattedDate from "@/utils/formattedDate"; +import { BoardItem } from "@/apis/boards"; +import Link from "next/link"; +import BoardImage from "../BoardImage/BoardImage"; + +interface AllItemProps { + all: BoardItem; +} + +export default function AllItem({ all }: AllItemProps) { + return ( + +
+

+ {all.content} +

+ +
+
+
+ user + + {all.writer.nickname} + + + {formattedDate(all.createdAt)} + +
+
+ like + {all.likeCount} +
+
+ + ); +} diff --git a/components/AllBoards/useAllData.tsx b/components/AllBoards/useAllData.tsx new file mode 100644 index 00000000..670ff145 --- /dev/null +++ b/components/AllBoards/useAllData.tsx @@ -0,0 +1,33 @@ +import { BoardItem, getBoards } from "@/apis/boards"; +import { useEffect, useState } from "react"; +import useParams from "@/hooks/useParams"; + +interface AllDataProps { + initialData: BoardItem[]; + PAGE_SIZE: number; +} + +export default function useAllData({ initialData, PAGE_SIZE }: AllDataProps) { + const [all, setAll] = useState(initialData); + const [totalBoards, setTotalBoards] = useState(0); + const { page, orderBy, keyword } = useParams(); + + useEffect(() => { + getBoards({ + page, + pageSize: keyword ? 1000 : PAGE_SIZE, + orderBy, + keyword, + }) + .then((result) => { + if (!result) return; + setAll(result.list); + if (result.totalCount) { + setTotalBoards(result.totalCount); + } + }) + .catch((error) => console.error(error)); + }, [page, orderBy, keyword, PAGE_SIZE]); + + return { all, totalBoards }; +} diff --git a/components/BestBoards/Best.tsx b/components/BestBoards/Best.tsx new file mode 100644 index 00000000..9888acfd --- /dev/null +++ b/components/BestBoards/Best.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { BoardItem } from "@/apis/boards"; +import BestItem from "./BestItem"; +import useBestData from "./useBestData"; + +interface BestDataProps { + initialData: BoardItem[]; +} + +export default function Best({ initialData }: BestDataProps) { + const { best } = useBestData(initialData); + + return ( +
+

베스트 게시글

+
+ {best.map((item) => ( + + ))} +
+
+ ); +} diff --git a/components/BestBoards/BestItem.tsx b/components/BestBoards/BestItem.tsx new file mode 100644 index 00000000..dca148eb --- /dev/null +++ b/components/BestBoards/BestItem.tsx @@ -0,0 +1,51 @@ +import Image from "next/image"; +import medal from "@/public/icons/best.svg"; +import heart from "@/public/icons/emptyHeart.svg"; +import { BoardItem } from "@/apis/boards"; +import formattedDate from "@/utils/formattedDate"; +import Link from "next/link"; +import BoardImage from "../BoardImage/BoardImage"; + +interface BestItemProps { + best: BoardItem; +} + +export default function BestItem({ best }: BestItemProps) { + return ( + +
+
+ medal + Best +
+
+
+

+ {best.content} +

+ +
+
+
+ + {best.writer.nickname} + +
+ like + + {best.likeCount} + +
+
+
+ {formattedDate(best.createdAt)} +
+
+
+
+ + ); +} diff --git a/components/BestBoards/useBestData.tsx b/components/BestBoards/useBestData.tsx new file mode 100644 index 00000000..4795cde2 --- /dev/null +++ b/components/BestBoards/useBestData.tsx @@ -0,0 +1,26 @@ +import { BoardItem, getBoards } from "@/apis/boards"; +import useResize from "@/hooks/useResize"; +import { useEffect, useState } from "react"; + +export default function useBestData(initialData: BoardItem[]) { + const [bestItems, setBestItems] = useState(initialData); + const { showItems } = useResize(1, 2, 3); + + const best = bestItems.slice(0, showItems); + + useEffect(() => { + getBoards({ + page: 1, + pageSize: showItems, + orderBy: "like", + keyword: "", + }) + .then((result) => { + if (!result) return; + setBestItems(result.list); + }) + .catch((error) => console.error(error)); + }, [showItems]); + + return { best }; +} diff --git a/components/BoardImage/BoardImage.tsx b/components/BoardImage/BoardImage.tsx new file mode 100644 index 00000000..76d2ec18 --- /dev/null +++ b/components/BoardImage/BoardImage.tsx @@ -0,0 +1,23 @@ +import Image from "next/image"; +import none from "@/public/icons/whitePanda.svg"; +import { useState } from "react"; + +export default function BoardImage({ image }: { image: string }) { + const [isError, setIsError] = useState(false); + + return ( +
+ {image && !isError ? ( + image setIsError(true)} + /> + ) : ( + none + )} +
+ ); +} diff --git a/components/Pagination/Pagination.tsx b/components/Pagination/Pagination.tsx new file mode 100644 index 00000000..33abb242 --- /dev/null +++ b/components/Pagination/Pagination.tsx @@ -0,0 +1,75 @@ +"use client"; + +import left from "@/public/icons/arrowLeft.svg"; +import right from "@/public/icons/arrowRight.svg"; +import usePagination from "./usePagination"; +import Link from "next/link"; +import Image from "next/image"; + +export interface PagingProps { + totalBoards: number; + currentPage: number; + pageSize: number; +} + +export default function Pagination({ + totalBoards, + currentPage, + pageSize, +}: PagingProps) { + const { startPage, endPage, totalPages, createPageParams } = usePagination({ + totalBoards, + currentPage, + pageSize, + }); + + return ( +
+ 1 ? createPageParams(currentPage - 1) : "#"} + shallow + scroll={false} + > + prev + + {Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i + ).map((page) => ( + + {page} + + ))} + + next + +
+ ); +} diff --git a/components/Pagination/usePagination.tsx b/components/Pagination/usePagination.tsx new file mode 100644 index 00000000..8aa20a87 --- /dev/null +++ b/components/Pagination/usePagination.tsx @@ -0,0 +1,29 @@ +import { useSearchParams } from "next/navigation"; +import { PagingProps } from "./Pagination"; + +export default function usePagination({ + totalBoards, + currentPage, + pageSize, +}: PagingProps) { + const searchParams = useSearchParams(); + + const pageGroup = Math.ceil(currentPage / 5); + const totalPages = Math.ceil(totalBoards / pageSize); + const startPage = (pageGroup - 1) * 5 + 1; + const endPage = Math.min(startPage + 4, totalPages); + + const createPageParams = (page: number) => { + const newParams = new URLSearchParams(searchParams.toString()); + + if (page === 1) { + newParams.delete("page"); + } else { + newParams.set("page", String(page)); + } + + return `?${newParams.toString()}`; + }; + + return { startPage, endPage, totalPages, createPageParams }; +} diff --git a/components/Search/Search.tsx b/components/Search/Search.tsx new file mode 100644 index 00000000..abf5b884 --- /dev/null +++ b/components/Search/Search.tsx @@ -0,0 +1,19 @@ +import Input from "../common/Input/Input"; +import search from "@/public/icons/search.svg"; +import useSearch from "./useSearch"; + +export default function Search() { + const { defaultValue, handleSearchChange } = useSearch(); + + return ( + <> + + + ); +} diff --git a/components/Search/useSearch.tsx b/components/Search/useSearch.tsx new file mode 100644 index 00000000..6afbeac1 --- /dev/null +++ b/components/Search/useSearch.tsx @@ -0,0 +1,30 @@ +import useParams from "@/hooks/useParams"; +import debounce from "lodash.debounce"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useMemo } from "react"; + +export default function useSearch() { + const searchParams = useSearchParams(); + const { handleParamsUpdate } = useParams(); + + const debouncedKeyword = useMemo( + () => + debounce( + (search: string) => handleParamsUpdate({ keyword: search }), + 300 + ), + [handleParamsUpdate] + ); + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + const search = e.target.value; + debouncedKeyword(search); + }, + [debouncedKeyword] + ); + + const defaultValue = searchParams.get("keyword")?.toString(); + + return { defaultValue, handleSearchChange }; +} diff --git a/components/common/Dropdown/Dropdown.tsx b/components/common/Dropdown/Dropdown.tsx new file mode 100644 index 00000000..7816cac9 --- /dev/null +++ b/components/common/Dropdown/Dropdown.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import down from "@/public/icons/arrowDown.svg"; +import up from "@/public/icons/arrowUp.svg"; +import dropdown from "@/public/icons/dropdown.svg"; + +interface DropDownProps { + orderBy: string; + onChange: (filter: string) => void; + list: string[]; +} + +export default function Dropdown({ + orderBy, + onChange, + list = [], +}: DropDownProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleOpenClick = () => { + setIsOpen((prev) => !prev); + }; + + return ( +
+
+ {orderBy} + arrow +
+
+ dropdown +
+ {isOpen && ( +
+ {list.map((item) => ( +
{ + onChange(item); + setIsOpen(false); + }} + > + {item} +
+ ))} +
+ )} +
+ ); +} diff --git a/components/common/Header/Header.tsx b/components/common/Header/Header.tsx new file mode 100644 index 00000000..f5f87971 --- /dev/null +++ b/components/common/Header/Header.tsx @@ -0,0 +1,61 @@ +"use client"; + +import logo from "@/public/icons/panda.svg"; +import user from "@/public/icons/user.svg"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function Header() { + const pathname = usePathname(); + + const Links = [ + { + link: "/boards", + name: "자유게시판", + }, + { + link: "/items", + name: "중고마켓", + }, + ]; + + return ( +
+
+
+ + logo + + 판다마켓 + + +
+ {Links.map((l) => ( + + {l.name} + + ))} +
+
+ user +
+
+ ); +} diff --git a/components/common/Input/Input.tsx b/components/common/Input/Input.tsx new file mode 100644 index 00000000..c8f9e053 --- /dev/null +++ b/components/common/Input/Input.tsx @@ -0,0 +1,66 @@ +import Image from "next/image"; +import { CSSProperties, InputHTMLAttributes,TextareaHTMLAttributes } from "react"; + +interface InputProps { + label?: string; + style?: CSSProperties; + leftSlot?: string; + slotSize?: number; + height?: string; + largeHeight?: string; +} + +type IOrTProps = + | (InputProps & InputHTMLAttributes & { isTextarea?: false }) + | (InputProps & TextareaHTMLAttributes & { isTextarea?: true }); + +export default function Input({ + label, + style, + isTextarea, + leftSlot, + slotSize, + height, + largeHeight, + ...rest +}: IOrTProps) { + return ( +
+ +
+ {leftSlot && ( +
+ +
+ )} + {isTextarea ? ( +