Skip to content

Commit d0c2fe9

Browse files
committed
게시글 좋아요 기능 추가
1 parent e86df42 commit d0c2fe9

File tree

8 files changed

+280
-1
lines changed

8 files changed

+280
-1
lines changed

src/app/api/posts/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getSearchedPost } from "./getSearchedPost";
77
import { incrementPostViews } from "./incrementPostViews";
88
import { postPost } from "./postPost";
99
import { putPost } from "./putPost";
10+
import { togglePostLike } from "./togglePostLike";
1011

1112
export {
1213
deletePost,
@@ -18,4 +19,5 @@ export {
1819
incrementPostViews,
1920
postPost,
2021
putPost,
22+
togglePostLike,
2123
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use server";
2+
3+
import { sql } from "@vercel/postgres";
4+
import { unstable_noStore as noStore, revalidatePath } from "next/cache";
5+
import { auth } from "@/auth";
6+
7+
/**
8+
* 게시글 좋아요를 토글합니다.
9+
* @param postIndex 게시글의 index
10+
* @returns 좋아요 상태 (true: 좋아요 추가됨, false: 좋아요 취소됨)
11+
*/
12+
export async function togglePostLike(postIndex: number): Promise<boolean> {
13+
noStore();
14+
15+
// 인증 체크
16+
const session = await auth();
17+
if (!session?.user) {
18+
throw new Error("Unauthorized: 로그인이 필요합니다.");
19+
}
20+
21+
const userEmail = session.user.email;
22+
if (!userEmail) {
23+
throw new Error("Unauthorized: 사용자 이메일 정보를 찾을 수 없습니다.");
24+
}
25+
26+
try {
27+
// 게시글 ID 가져오기
28+
const { rows: postRows } = await sql`
29+
SELECT id, likes FROM posts WHERE index = ${postIndex} AND status = 'published'
30+
`;
31+
32+
if (!postRows[0]) {
33+
throw new Error("게시글을 찾을 수 없습니다.");
34+
}
35+
36+
const postId = postRows[0].id;
37+
const currentLikes = postRows[0].likes || [];
38+
39+
// 사용자 ID 가져오기
40+
const { rows: userRows } = await sql`
41+
SELECT id FROM users WHERE email = ${userEmail}
42+
`;
43+
44+
if (!userRows[0]) {
45+
throw new Error("사용자 정보를 찾을 수 없습니다.");
46+
}
47+
48+
const userId = userRows[0].id;
49+
50+
// 좋아요 배열이 문자열인 경우 파싱
51+
let likesArray: string[] = [];
52+
if (typeof currentLikes === "string") {
53+
try {
54+
likesArray = JSON.parse(currentLikes);
55+
} catch {
56+
likesArray = Array.isArray(currentLikes) ? currentLikes : [];
57+
}
58+
} else if (Array.isArray(currentLikes)) {
59+
likesArray = currentLikes;
60+
}
61+
62+
// 좋아요 토글
63+
const isLiked = likesArray.includes(userId);
64+
let newLikes: string[];
65+
let isNowLiked: boolean;
66+
67+
if (isLiked) {
68+
// 좋아요 취소
69+
newLikes = likesArray.filter((id) => id !== userId);
70+
isNowLiked = false;
71+
} else {
72+
// 좋아요 추가
73+
newLikes = [...likesArray, userId];
74+
isNowLiked = true;
75+
}
76+
77+
// 데이터베이스 업데이트 (likes 컬럼은 uuid[] 타입)
78+
await sql`
79+
UPDATE posts
80+
SET likes = ${newLikes}
81+
WHERE id = ${postId}
82+
`;
83+
84+
revalidatePath(`/posts/${postIndex}`);
85+
revalidatePath("/posts");
86+
revalidatePath("/");
87+
88+
return isNowLiked;
89+
} catch (error) {
90+
console.error("Database Error:", error);
91+
throw new Error(
92+
error instanceof Error
93+
? error.message
94+
: "좋아요 처리 중 오류가 발생했습니다."
95+
);
96+
}
97+
}

src/app/posts/[index]/page.module.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,12 @@
1919
font-size: var(--h4);
2020
font-weight: 700;
2121
}
22+
23+
.post_actions {
24+
display: flex;
25+
align-items: center;
26+
justify-content: flex-end;
27+
width: 100%;
28+
gap: var(--gap);
29+
margin-top: var(--gap);
30+
}

src/app/posts/[index]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PostContent from "@/components/posts/[id]/PostContent";
44
import { getPost, incrementPostViews, getPrevNextPost } from "@/app/api/posts";
55
import PostNavigation from "@/components/posts/[id]/PostNavigation";
66
import { Comments } from "@/components/posts/[id]/Comments";
7+
import { PostLikeButton } from "@/components/posts/[id]/PostLikeButton";
78
import { notFound } from "next/navigation";
89

910
type Props = {
@@ -57,6 +58,9 @@ export default async function PostDetailPage({ params }: Props) {
5758
<main id="main-page" role="main" className={styles.main}>
5859
<h1 className={styles.title}>{post.title}</h1>
5960
<PostContent post={post} />
61+
<div className={styles.post_actions}>
62+
<PostLikeButton post={post} />
63+
</div>
6064
<PostNavigation previousPost={previousPost} nextPost={nextPost} />
6165
<Comments postIndex={postIndex} />
6266
</main>

src/components/common/header/PostsHeader/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const PostsHeader = () => {
7575
</button>
7676
<Link
7777
href={isEdit ? `/posts/${index}` : "/posts"}
78-
className={`button-card-shadow ${styles.edit_button} ${
78+
className={`${styles.cancel_button} ${
7979
loading && styles.loading
8080
}`}
8181
onClick={() => handleButton("cancel")}

src/components/common/header/PostsHeader/styles.module.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@
128128
}
129129
}
130130

131+
.cancel_button {
132+
padding: 2px 6px;
133+
border: none;
134+
background: transparent;
135+
color: var(--secondary-color);
136+
font-size: var(--caption);
137+
font-weight: 400;
138+
cursor: pointer;
139+
text-decoration: none;
140+
transition: opacity 0.15s ease-in-out;
141+
opacity: 0.6;
142+
143+
&:hover {
144+
opacity: 1;
145+
}
146+
147+
&:active {
148+
opacity: 0.5;
149+
}
150+
}
151+
131152
.loading {
132153
cursor: not-allowed;
133154
opacity: 0.5;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useAppSelector } from "@/hooks/reduxHook";
5+
import { togglePostLike } from "@/app/api/posts";
6+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
7+
import { faHeart as solidHeart } from "@fortawesome/free-solid-svg-icons";
8+
import { faHeart as regularHeart } from "@fortawesome/free-regular-svg-icons";
9+
import styles from "./styles.module.scss";
10+
11+
interface PostLikeButtonProps {
12+
post: IPost;
13+
onLikeToggle?: (isLiked: boolean, newLikeCount: number) => void;
14+
}
15+
16+
export const PostLikeButton = ({ post, onLikeToggle }: PostLikeButtonProps) => {
17+
const user = useAppSelector((state) => state.user);
18+
const [isPending, startTransition] = useTransition();
19+
20+
// likes가 배열인지 확인하고 파싱
21+
const parseLikes = (likes: any): string[] => {
22+
if (Array.isArray(likes)) {
23+
return likes;
24+
}
25+
if (typeof likes === "string") {
26+
try {
27+
const parsed = JSON.parse(likes);
28+
return Array.isArray(parsed) ? parsed : [];
29+
} catch {
30+
return [];
31+
}
32+
}
33+
return [];
34+
};
35+
36+
const likesArray = parseLikes(post.likes);
37+
const [isLiked, setIsLiked] = useState(
38+
user.id ? likesArray.includes(user.id) : false
39+
);
40+
const [likeCount, setLikeCount] = useState(likesArray.length);
41+
42+
const handleLike = async () => {
43+
if (!user.id || !user.email) {
44+
alert("로그인이 필요합니다.");
45+
return;
46+
}
47+
48+
startTransition(async () => {
49+
try {
50+
const newIsLiked = await togglePostLike(post.index);
51+
setIsLiked(newIsLiked);
52+
const newCount = newIsLiked ? likeCount + 1 : likeCount - 1;
53+
setLikeCount(newCount);
54+
onLikeToggle?.(newIsLiked, newCount);
55+
} catch (error) {
56+
console.error("Failed to toggle like:", error);
57+
alert(
58+
error instanceof Error
59+
? error.message
60+
: "좋아요 처리 중 오류가 발생했습니다."
61+
);
62+
}
63+
});
64+
};
65+
66+
if (!user.id || !user.email) {
67+
return null; // 로그인하지 않은 사용자에게는 버튼을 표시하지 않음
68+
}
69+
70+
return (
71+
<button
72+
className={styles.like_button}
73+
onClick={handleLike}
74+
disabled={isPending}
75+
aria-label={isLiked ? "좋아요 취소" : "좋아요"}
76+
>
77+
<FontAwesomeIcon
78+
icon={isLiked ? solidHeart : regularHeart}
79+
className={`${styles.heart_icon} ${isLiked ? styles.liked : styles.not_liked}`}
80+
/>
81+
<span className={styles.like_count}>{likeCount}</span>
82+
</button>
83+
);
84+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
.like_button {
2+
display: inline-flex;
3+
align-items: center;
4+
gap: 6px;
5+
padding: 0;
6+
background: transparent;
7+
border: none;
8+
cursor: pointer;
9+
transition: opacity 0.15s ease-in-out;
10+
11+
&:hover:not(:disabled) {
12+
opacity: 0.7;
13+
}
14+
15+
&:active:not(:disabled) {
16+
opacity: 0.5;
17+
}
18+
19+
&:disabled {
20+
opacity: 0.5;
21+
cursor: not-allowed;
22+
}
23+
}
24+
25+
.heart_icon {
26+
font-size: 20px;
27+
line-height: 1;
28+
transition: transform 0.2s ease-in-out;
29+
}
30+
31+
.liked {
32+
color: var(--error-color);
33+
34+
.like_button:active & {
35+
animation: heartBeat 0.3s ease-in-out;
36+
}
37+
}
38+
39+
.not_liked {
40+
color: var(--text-color);
41+
opacity: 0.8;
42+
}
43+
44+
.like_count {
45+
color: var(--text-color);
46+
font-size: var(--p);
47+
font-weight: 400;
48+
line-height: 1;
49+
margin-left: 2px;
50+
}
51+
52+
@keyframes heartBeat {
53+
0% {
54+
transform: scale(1);
55+
}
56+
50% {
57+
transform: scale(1.2);
58+
}
59+
100% {
60+
transform: scale(1);
61+
}
62+
}

0 commit comments

Comments
 (0)