diff --git a/next.config.js b/next.config.js index da265d11..3eeba39b 100644 --- a/next.config.js +++ b/next.config.js @@ -2,7 +2,12 @@ const nextConfig = { reactStrictMode: true, images: { - domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com'], + domains: [ + 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com', + 'example.com', + 'via.placeholder.com', + 'flexible.img.hani.co.kr', + ], }, }; diff --git a/pages/boards/[id].tsx b/pages/boards/[id].tsx new file mode 100644 index 00000000..908547fa --- /dev/null +++ b/pages/boards/[id].tsx @@ -0,0 +1,9 @@ +import { useRouter } from 'next/router'; +import styles from './[id].module.css'; + +export default function BoardDetail() { + const router = useRouter(); + const { id } = router.query; + + return
{id}
; +} diff --git a/pages/boards/components/BestBoardCard.tsx b/pages/boards/components/BestBoardCard.tsx index 555d7496..1253117e 100644 --- a/pages/boards/components/BestBoardCard.tsx +++ b/pages/boards/components/BestBoardCard.tsx @@ -1,8 +1,8 @@ import Image from 'next/image'; import styles from './BestBoardCard.module.css'; import medalSvg from '@/src/assets/ic_medal.svg'; -import heardSvg from '@/src/assets/ic_heart.svg'; -import { Board } from '@/src/apis/boardTypes'; +import heartSvg from '@/src/assets/ic_heart.svg'; +import type { Board } from '@/src/apis/boardTypes'; interface BestBoardCardProps extends Pick< @@ -26,13 +26,18 @@ export default function BestBoardCard({

{title}

- medal + 게시판 첨부이미지
{writer.nickname}
- heardIcon + heartIcon {likeCount}
{new Date(createdAt).toLocaleDateString()} diff --git a/pages/boards/components/BestBoards.tsx b/pages/boards/components/BestBoards.tsx index 5a81735e..3f3833af 100644 --- a/pages/boards/components/BestBoards.tsx +++ b/pages/boards/components/BestBoards.tsx @@ -1,4 +1,4 @@ -import { Board } from '@/src/apis/boardTypes'; +import type { Board } from '@/src/apis/boardTypes'; import BestBoardCard from './BestBoardCard'; import styles from './BestBoards.module.css'; diff --git a/pages/boards/components/BoardCard.module.css b/pages/boards/components/BoardCard.module.css new file mode 100644 index 00000000..dbb8235e --- /dev/null +++ b/pages/boards/components/BoardCard.module.css @@ -0,0 +1,74 @@ +.boardCard { + background: #fcfcfc; + padding: 20px; + position: relative; + display: flex; + flex-direction: column; + gap: 16px; +} + +.boardCard::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 1px; + background-color: var(--Secondary-200); +} + +.contentContainer { + display: flex; + gap: 8px; + min-height: 72px; +} + +.title { + color: var(--Secondary-800); + font-size: 20px; + font-weight: 600; + + flex: 1; +} + +.imageWrapper { + position: relative; + width: 72px; + height: 72px; + + border-radius: 6px; + border: 1px solid var(--Secondary-200, #e5e7eb); + background: #fff; +} + +.additionalInfo { + display: flex; + justify-content: space-between; +} + +.infoWrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.nickname { + color: var(--Secondary-600, #4b5563); + font-size: 14px; + font-weight: 400; +} + +.date { + color: var(--Secondary-400, #9ca3af); + font-size: 14px; + font-weight: 400; +} + +.likeCountWrapper { + display: flex; + align-items: center; + gap: 8px; + color: var(--Secondary-500, #6b7280); + font-size: 16px; + font-weight: 400; +} diff --git a/pages/boards/components/BoardCard.tsx b/pages/boards/components/BoardCard.tsx new file mode 100644 index 00000000..9ff1f2d9 --- /dev/null +++ b/pages/boards/components/BoardCard.tsx @@ -0,0 +1,44 @@ +import Image from 'next/image'; +import styles from './BoardCard.module.css'; +import type { Board } from '@/src/apis/boardTypes'; +import heartSvg from '@/src/assets/ic_heart.svg'; +import avatarSvg from '@/src/assets/avatar.svg'; + +export default function BoardCard({ + title, + image, + writer, + createdAt, + likeCount, +}: Board) { + return ( +
+
+

{title}

+ {image && ( +
+ 게시판 첨부이미지 +
+ )} +
+
+
+ avatar + {writer.nickname} + + {new Date(createdAt).toLocaleDateString()} + +
+
+ heardIcon + {likeCount} +
+
+
+ ); +} diff --git a/pages/boards/components/Boards.module.css b/pages/boards/components/Boards.module.css index fdc84945..18733bfe 100644 --- a/pages/boards/components/Boards.module.css +++ b/pages/boards/components/Boards.module.css @@ -11,10 +11,16 @@ font-weight: 700; } +.filter { + display: flex; + gap: 20px; +} + .searchBar { padding: 9px 20px 9px 16px; border-radius: 12px; background: var(--Secondary-100, #f3f4f6); + flex: 1; display: flex; align-items: center; @@ -31,6 +37,27 @@ color: var(--Secondary-400, #9ca3af); } +.options { + height: 100%; + padding: 12px 20px; + padding-right: 50px; + + border-radius: 12px; + border: 1px solid var(--Secondary-200, #e5e7eb); + + appearance: none; + background: url('../../../src/assets/ic_arrow_down.svg') no-repeat right 10px + center; +} + +.boardsContainer { + margin-top: 24px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 24px; +} + /* tablet */ @media screen and (max-width: 1199px) { .boardsHeader { diff --git a/pages/boards/components/Boards.tsx b/pages/boards/components/Boards.tsx index 1d4d8495..b88484a6 100644 --- a/pages/boards/components/Boards.tsx +++ b/pages/boards/components/Boards.tsx @@ -2,17 +2,31 @@ import Image from 'next/image'; import Button from '@/src/components/Button'; import styles from './Boards.module.css'; import searchSvg from '@/src/assets/ic_search.svg'; +import { useState } from 'react'; +import BoardCard from './BoardCard'; +import Link from 'next/link'; +import { useBoards } from '@/src/hooks/useBoards'; export default function Boards() { + const [orderBy, setOrderBy] = useState('recent'); + const { boards, isLoading, error, observerRef, resetBoards } = + useBoards(orderBy); + + const handleOptionChange = (e: React.ChangeEvent) => { + const newOrder = e.target.value; + setOrderBy(newOrder); + resetBoards(); + }; + return (
-
+

게시글

-
-
+ +
searchIcon
-
+
+ +
+ +
+ {boards.map((board) => ( + + + + ))} + {isLoading &&
Loading...
} + {error &&
{error}
} +
+
); } diff --git a/pages/boards/index.tsx b/pages/boards/index.tsx index 5e584f2c..599e6e54 100644 --- a/pages/boards/index.tsx +++ b/pages/boards/index.tsx @@ -1,6 +1,6 @@ import { GetStaticProps } from 'next'; import BestBoards from './components/BestBoards'; -import { Board } from '@/src/apis/boardTypes'; +import type { Board } from '@/src/apis/boardTypes'; import { getBoards } from '@/src/apis/boardsApi'; import Boards from './components/Boards'; @@ -28,6 +28,6 @@ export const getStaticProps: GetStaticProps = async () => { props: { boards: list || [], }, - revalidate: 600, // Re-generate the page every 600 seconds (ISR) + revalidate: 600, }; }; diff --git a/src/apis/boardTypes.ts b/src/apis/boardTypes.ts index e44f3fdf..babe3662 100644 --- a/src/apis/boardTypes.ts +++ b/src/apis/boardTypes.ts @@ -22,6 +22,6 @@ export interface GetBoardsResponse { export interface GetBoardsRequestParams { page?: number; pageSize?: number; - orderBy?: 'recent' | 'like'; + orderBy?: string; keyword?: string; } diff --git a/src/apis/boardsApi.ts b/src/apis/boardsApi.ts index d0b14090..65cbfb9b 100644 --- a/src/apis/boardsApi.ts +++ b/src/apis/boardsApi.ts @@ -1,4 +1,4 @@ -import { Board, GetBoardsResponse, GetBoardsRequestParams } from './boardTypes'; +import type { GetBoardsResponse, GetBoardsRequestParams } from './boardTypes'; const BASE_URL = 'https://panda-market-api.vercel.app'; diff --git a/src/assets/ic_arrow_down.svg b/src/assets/ic_arrow_down.svg new file mode 100644 index 00000000..ad19ce55 --- /dev/null +++ b/src/assets/ic_arrow_down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 56cd4dd6..682bfaca 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,12 +3,11 @@ import styles from './Button.module.css'; interface PrimaryButtonProps extends ButtonHTMLAttributes { children: React.ReactNode; - className?: string; } export default function Button({ children, - className, + className = '', ...props }: PrimaryButtonProps) { return ( diff --git a/src/components/GlobalLayout.module.css b/src/components/GlobalLayout.module.css index 2ccbc164..5e2bfc9a 100644 --- a/src/components/GlobalLayout.module.css +++ b/src/components/GlobalLayout.module.css @@ -2,3 +2,22 @@ margin-top: 75px; width: 100%; } + +.maxContainer { + max-width: var(--size-max-width); + margin: auto; +} + +/* tablet */ +@media screen and (max-width: 1199px) { + .maxContainer { + max-width: 760px; + } +} + +/* mobile */ +@media screen and (max-width: 767px) { + .maxContainer { + max-width: 360px; + } +} diff --git a/src/components/GlobalLayout.tsx b/src/components/GlobalLayout.tsx index f4ce9201..6af4010e 100644 --- a/src/components/GlobalLayout.tsx +++ b/src/components/GlobalLayout.tsx @@ -7,7 +7,7 @@ export default function GlobalLayout({ children }: { children: ReactNode }) { <>
-
{children}
+
{children}
); diff --git a/src/components/Header.module.css b/src/components/Header.module.css index 49aa3e69..8bd7f1d8 100644 --- a/src/components/Header.module.css +++ b/src/components/Header.module.css @@ -6,6 +6,10 @@ border-bottom: 1px solid #dfdfdf; } +.active { + color: var(--Primary-100); +} + .headerContainer { padding: 10px 0; display: flex; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1f5362ba..ccd1e14b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,19 +2,32 @@ import Link from 'next/link'; import styles from './Header.module.css'; import Image from 'next/image'; import avatarSvg from '@/src/assets/avatar.svg'; +import { usePathname } from 'next/navigation'; export default function Header() { + const pathname = usePathname(); + return (
-
+
avatar diff --git a/src/hooks/useApi.tsx b/src/hooks/useApi.tsx new file mode 100644 index 00000000..e683bc07 --- /dev/null +++ b/src/hooks/useApi.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState, useCallback } from 'react'; + +type ApiResponse = { + data: T | null; + isLoading: boolean; + error: string | null; + makeRequest: (params: P) => Promise; +}; + +export function useApi( + fetchFunction: (params: P) => Promise, + params: P +): ApiResponse { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const wrappedFunction = useCallback( + async (params: P) => { + setIsLoading(true); + setError(null); + + try { + const result = await fetchFunction(params); + setData(result); + } catch (err) { + console.error(err); + setError('데이터 로딩 실패'); + } finally { + setIsLoading(false); + } + }, + [fetchFunction] + ); + + useEffect(() => { + wrappedFunction(params); + }, [JSON.stringify(params), wrappedFunction]); + + return { data, isLoading, error, makeRequest: wrappedFunction }; +} diff --git a/src/hooks/useBoards.ts b/src/hooks/useBoards.ts new file mode 100644 index 00000000..4bc1c3b0 --- /dev/null +++ b/src/hooks/useBoards.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react'; +import { getBoards } from '@/src/apis/boardsApi'; +import type { Board } from '@/src/apis/boardTypes'; + +const PAGE_SIZE = 10; + +export const useBoards = (orderBy: string) => { + const [boards, setBoards] = useState([]); + const [isFetching, setIsFetching] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const observerRef = useRef(null); + + const fetchBoards = async () => { + setIsLoading(true); + setError(null); + try { + const response = await getBoards({ + page, + pageSize: PAGE_SIZE, + orderBy, + }); + setBoards((prevBoards) => [...prevBoards, ...response.list]); + setIsFetching(false); + + if (response.list.length < PAGE_SIZE) { + setHasMore(false); + } + } catch (err) { + setError('Failed to fetch boards'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchBoards(); + }, [page, orderBy]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetching && hasMore) { + setIsFetching(true); + setPage((prevPage) => prevPage + 1); + } + }, + { threshold: 0.5 } + ); + + const current = observerRef.current; + if (current) observer.observe(current); + + return () => { + if (current) observer.unobserve(current); + }; + }, [isFetching, hasMore]); + + const resetBoards = () => { + setBoards([]); + setPage(1); + setHasMore(true); + }; + + return { + boards, + isLoading, + error, + observerRef, + resetBoards, + }; +}; diff --git a/styles/globals.css b/styles/globals.css index 14b366ae..4e36ccef 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -29,22 +29,3 @@ header { footer { background-color: var(--Secondary-900); } - -.max-container { - max-width: var(--size-max-width); - margin: auto; -} - -/* tablet */ -@media screen and (max-width: 1199px) { - .max-container { - max-width: 760px; - } -} - -/* mobile */ -@media screen and (max-width: 767px) { - .max-container { - max-width: 360px; - } -}