Skip to content

Commit 56b89d5

Browse files
authored
Merge pull request #54 from codeit9-temporary/feature/favorite-api
Feat : 즐겨찾기 페이지 API 구현
2 parents 3908185 + 763cb56 commit 56b89d5

File tree

7 files changed

+169
-47
lines changed

7 files changed

+169
-47
lines changed

components/Layout/CardsLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface CardsLayoutProps {
66

77
const CardsLayout = ({ children }: CardsLayoutProps) => {
88
return (
9-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 lg:gap-[10px] w-full">
9+
<div className="grid place-items-center grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 lg:gap-[20px] w-full">
1010
{children}
1111
</div>
1212
);

components/Layout/Container.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface ContainerProps {
66

77
const Container = ({ children }: ContainerProps) => {
88
return (
9-
<div className="w-full max-w-[1125px] mx-auto p-[10px] md:p-10 px-[32.5px]">
9+
<div className="w-full max-w-[1125px] mx-auto p-[10px] md:p-10 lg:p-10 px-[32.5px]">
1010
{children}
1111
</div>
1212
);

components/LinkCard.tsx

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,66 +6,75 @@ interface linkDataType {
66
id: number;
77
title: string;
88
description: string;
9-
favorite: boolean;
9+
favorite?: boolean;
1010
imageSource: string;
1111
url: string;
1212
createdAt: string;
1313
}
1414

15-
const LinkCard = (info: linkDataType) => {
16-
const [isSubscribed, seIsSubscribed] = useState(false);
15+
interface CardItemProps extends linkDataType {
16+
isFavoritePage?: boolean; // 즐겨찾기 페이지 여부를 판별하는 flag
17+
}
18+
19+
const LinkCard = ({ isFavoritePage, ...info }: CardItemProps) => {
20+
const [isSubscribed, setIsSubscribed] = useState(false);
1721
const [isOpen, setIsOpen] = useState(false);
1822

1923
const formattedDate = info.createdAt?.slice(0, 10).replace(/-/g, ".");
2024
const createdTime = timeAgo(info.createdAt);
2125

2226
return (
23-
<div className="w-[340px] h-[344px] rounded-[12px] shadow-lg mt-20 ml-20 overflow-hidden cursor-pointer hover:scale-105 hover:duration-300">
27+
<div className="w-[340px] h-[344px] rounded-[12px] shadow-lg overflow-hidden cursor-pointer hover:scale-105 hover:duration-300">
2428
<section className="relative w-full h-[60%]">
2529
<Image
2630
src={info.imageSource || `/images/no-content.svg`}
27-
objectFit="cover"
31+
className="object-cover"
2832
alt="링크 미리보기"
2933
fill
3034
/>
31-
{isSubscribed ? (
32-
<div
33-
onClick={() => seIsSubscribed(!isSubscribed)}
34-
className="absolute top-[15px] right-[15px] z-1"
35-
>
36-
<Image
37-
src="/icons/star-fill.svg"
38-
width={32}
39-
height={32}
40-
alt="subscripe button"
41-
/>
42-
</div>
43-
) : (
44-
<div
45-
onClick={() => seIsSubscribed(!isSubscribed)}
46-
className="absolute top-[15px] right-[15px] z-1"
47-
>
48-
<Image
49-
src="/icons/star-empty.svg"
50-
width={32}
51-
height={32}
52-
alt="subscripe button"
53-
/>
54-
</div>
55-
)}
35+
{/* isFavoritePage가 false일 때만 즐겨찾기 버튼 렌더링 */}
36+
{!isFavoritePage &&
37+
(isSubscribed ? (
38+
<div
39+
onClick={() => setIsSubscribed(!isSubscribed)}
40+
className="absolute top-[15px] right-[15px] z-1"
41+
>
42+
<Image
43+
src="/icons/star-fill.svg"
44+
width={32}
45+
height={32}
46+
alt="subscripe button"
47+
/>
48+
</div>
49+
) : (
50+
<div
51+
onClick={() => setIsSubscribed(!isSubscribed)}
52+
className="absolute top-[15px] right-[15px] z-1"
53+
>
54+
<Image
55+
src="/icons/star-empty.svg"
56+
width={32}
57+
height={32}
58+
alt="subscripe button"
59+
/>
60+
</div>
61+
))}
5662
</section>
5763

5864
<section className="w-full h-[40%] flex flex-col justify-between gap-[10px] pt-[15px] px-[20px] pb-[10px]">
5965
<div className="flex justify-between">
6066
<span className="text-sm text-gray-400">
6167
{createdTime || "1일 전"}
6268
</span>
63-
<div
64-
className="relative w-[21px] h-[17px]"
65-
onClick={(state) => setIsOpen(!state)}
66-
>
67-
<Image src="/icons/kebab.svg" alt="kebab button" fill />
68-
</div>
69+
{/* isFavoritePage가 false일 때만 케밥 버튼 렌더링 */}
70+
{!isFavoritePage && (
71+
<div
72+
className="relative w-[21px] h-[17px]"
73+
onClick={(state) => setIsOpen(!state)}
74+
>
75+
<Image src="/icons/kebab.svg" alt="kebab button" fill />
76+
</div>
77+
)}
6978
</div>
7079
<div className="text-[black100] text-lg ">
7180
{info.description || "설명"}

lib/api/axiosInstanceApi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const axiosInstance = axios.create({
99
export const proxy = axios.create({
1010
// 배포 이후에는 배포된 URL로 변경해야 함.
1111
// baseURL: "http://localhost:3000", -> baseURL을 안쓰면 로컬에서는 로컬3000, 배포했을때는 배포된 도메인으로 감
12+
baseURL: "http://localhost:3000",
1213
timeout: 5000,
1314
withCredentials: true,
1415
});

next.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import type { NextConfig } from "next";
33
const nextConfig: NextConfig = {
44
/* config options here */
55
reactStrictMode: true,
6+
// 외부 이미지 도메인 추가
7+
images: {
8+
remotePatterns: [
9+
{
10+
protocol: "https",
11+
hostname: "*",
12+
pathname: "/**",
13+
},
14+
],
15+
},
616
};
717

818
export default nextConfig;

pages/api/favorites/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { NextApiRequest, NextApiResponse } from "next";
2+
import { parse } from "cookie";
3+
import axiosInstance from "@/lib/api/axiosInstanceApi";
4+
5+
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
6+
const cookies = parse(req.headers.cookie || "");
7+
const accessToken = cookies.accessToken;
8+
9+
if (!accessToken) {
10+
return res.status(401).json({ message: "인증 오류: 토큰이 없습니다." });
11+
}
12+
13+
switch (req.method) {
14+
case "GET":
15+
// 즐겨찾기 목록 조회
16+
try {
17+
const response = await axiosInstance.get(
18+
"/favorites?page=1&pageSize=10",
19+
{
20+
headers: { Authorization: `Bearer ${accessToken}` },
21+
}
22+
);
23+
return res.status(200).json(response.data);
24+
} catch (err: any) {
25+
// 즐겨찾기 폴더가 없는 경우 (404 처리)
26+
if (err.response?.status === 404) {
27+
return res.status(404).json({ message: "즐겨찾기 폴더가 없습니다." });
28+
}
29+
30+
console.error(err);
31+
return res
32+
.status(500)
33+
.json({ message: "서버 에러 : 즐겨찾기 목록 조회에 실패했습니다." });
34+
}
35+
default:
36+
// 지원하지 않는 메서드
37+
res.setHeader("Allow", ["GET"]);
38+
return res.status(405).end(`메서드 ${req.method}는 허용되지 않습니다.`);
39+
}
40+
};
41+
export default handler;

pages/favorite/index.tsx

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,48 @@
1+
import { GetServerSideProps, GetServerSidePropsContext } from "next";
2+
import { proxy } from "@/lib/api/axiosInstanceApi";
3+
import LinkCard from "@/components/LinkCard";
14
import CardsLayout from "@/components/Layout/CardsLayout";
25
import Container from "@/components/Layout/Container";
36

4-
const Favorite = () => {
7+
interface FavoriteDataType {
8+
id: number;
9+
favorite: boolean;
10+
url: string;
11+
title: string;
12+
imageSource: string;
13+
description: string;
14+
createdAt: string;
15+
}
16+
17+
interface FavoriteProps {
18+
totalCount: number;
19+
favoriteList: FavoriteDataType[];
20+
}
21+
22+
export const getServerSideProps: GetServerSideProps = async (
23+
context: GetServerSidePropsContext
24+
) => {
25+
const { req } = context;
26+
27+
// 클라이언트의 쿠키 가져오기
28+
const cookies = req.headers.cookie || "";
29+
30+
try {
31+
const res = await proxy.get("/api/favorites", {
32+
headers: {
33+
Cookie: cookies, // 쿠키를 그대로 포함시킴
34+
},
35+
});
36+
37+
const { list, totalCount } = res.data || { list: [], totalCount: 0 };
38+
return { props: { favoriteList: list, totalCount } };
39+
} catch (error) {
40+
console.error("서버사이드에러", error);
41+
return { props: { favoriteList: [], totalCount: 0 } };
42+
}
43+
};
44+
45+
const FavoritePage = ({ favoriteList, totalCount }: FavoriteProps) => {
546
return (
647
<>
748
<div className="page-title pt-[10px] md:pt-5 pb-10 md:pb-[60px] bg-gray100 text-center">
@@ -11,17 +52,37 @@ const Favorite = () => {
1152
</div>
1253
<Container>
1354
<CardsLayout>
14-
{/* 카드 공통 컴포넌트로 구현 예정 */}
15-
<div className="border border-red-800">card</div>
16-
<div className="border border-red-800">card</div>
17-
<div className="border border-red-800">card</div>
18-
<div className="border border-red-800">card</div>
19-
<div className="border border-red-800">card</div>
20-
<div className="border border-red-800">card</div>
55+
{favoriteList.length > 0
56+
? favoriteList.map((favorite) => (
57+
<LinkCard
58+
key={favorite.id}
59+
id={favorite.id}
60+
url={favorite.url}
61+
title={favorite.title}
62+
imageSource={favorite.imageSource}
63+
description={favorite.description}
64+
createdAt={favorite.createdAt}
65+
isFavoritePage={true}
66+
/>
67+
))
68+
: null}
2169
</CardsLayout>
70+
71+
{/* 즐겨찾기 항목이 없을 때 보여줄 메시지 (공통 컴포넌트로 사용할 건지 논의 필요) */}
72+
{favoriteList.length === 0 && (
73+
<div className="flex flex-col justify-center items-center h-full p-10 bg-gray100 text-center text-gray600">
74+
<div className="text-2xl md:text-3xl font-semibold text-gray600">
75+
<span className="block mb-4">⭐️</span>
76+
즐겨찾기 항목이 없습니다.
77+
</div>
78+
<div className="text-sm text-purple100 mt-2">
79+
저장한 즐겨찾기 항목을 추가해보세요.
80+
</div>
81+
</div>
82+
)}
2283
</Container>
2384
</>
2485
);
2586
};
2687

27-
export default Favorite;
88+
export default FavoritePage;

0 commit comments

Comments
 (0)