Skip to content

Commit 860f4ba

Browse files
authored
Merge pull request #198 from Moaguide-develop/feat/articleDetail
feat: 아티클 상세 페이지 작업
2 parents 793bbf0 + dab2288 commit 860f4ba

15 files changed

+214
-53
lines changed
+3
Loading
+8
Loading
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Navbar from '@/components/common/Navbar';
12
import ArticleDetailClientWrapper from '@/components/learning/article/ArticleDetailClientWrapper';
23

34
interface PageProps {
@@ -8,8 +9,9 @@ export default async function ArticleDetailPage({ params }: PageProps) {
89
const articleId = params.articleId;
910

1011
return (
11-
<ArticleDetailClientWrapper
12-
articleId={Number(articleId)}
13-
/>
12+
<>
13+
<Navbar />
14+
<ArticleDetailClientWrapper articleId={Number(articleId)} />
15+
</>
1416
);
1517
}

src/components/common/GnbWrapper.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const GnbWrapper = () => {
99
const [isGnbHidden, setIsGnbHidden] = useState(false);
1010
useEffect(() => {
1111
const checkIfGnbShouldBeHidden = () => {
12-
const pathsToHideGnb = ['/signup', '/sign', '/find', '/detail', '/login', '/quiz'];
12+
const pathsToHideGnb = ['/signup', '/sign', '/find', '/login', '/quiz'];
1313
const shouldHideGnb = pathsToHideGnb.some((path) => pathname.includes(path));
1414
setIsGnbHidden(shouldHideGnb);
1515
};

src/components/common/Navbar.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const Navbar = () => {
77
const router = useRouter();
88
const pathname = usePathname();
99
return (
10-
<div className="hidden bg-white shadow-custom-light border-b desk:h-[60px] md:h-full border-gray100 sm:block desk:w-full lg:w-[100%] sticky top-[58px] z-[99998]">
10+
<div className="hidden bg-white shadow-custom-light border-b md:h-full border-gray100 sm:block desk:w-full lg:w-[100%] sticky top-[58px] z-[99998]">
1111
<div className="max-w-[1000px] mx-auto flex items-center">
1212
<div
1313
onClick={() => {
@@ -41,7 +41,7 @@ const Navbar = () => {
4141
router.push('/practicepage');
4242
}}
4343
className={` desk:whitespace-nowrap px-4 py-3 flex-1 flex justify-center items-center cursor-pointer text-body5 desk2:text-heading4
44-
${pathname === '/practicepage' ? 'text-black border-b-[2px] border-black' : 'text-gray300'}
44+
${pathname.startsWith('/learning') ? 'text-black border-b-[2px] border-black' : 'text-gray300'}
4545
`}>
4646
학습하기
4747
</div>

src/components/learning/FilteredContents.tsx

+1-9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useRouter } from 'next/navigation';
22
import Image from 'next/image';
33
import defaultImage from '../../../public/images/learning/learning_img.svg';
44
import { FilteredResponse } from '@/types/filterArticle';
5+
import { getValidImageSrc } from '@/utils/checkImageProperty';
56

67
interface FilteredContentsProps {
78
contents: FilteredResponse['content'];
@@ -26,15 +27,6 @@ const FilteredContents = ({
2627
return date.toISOString().split('T')[0];
2728
};
2829

29-
const getValidImageSrc = (imgLink: string | null) => {
30-
if (!imgLink || imgLink === '테스트') {
31-
return defaultImage;
32-
}
33-
if (imgLink.startsWith('http://') || imgLink.startsWith('https://')) {
34-
return imgLink;
35-
}
36-
return defaultImage;
37-
};
3830
return (
3931
<div className="mt-10">
4032
<div className="space-y-10">

src/components/learning/LearningPageClient.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ const LearningPageClient = ({ initialData }: { initialData: OverviewResponse })
6868
}))
6969
: [];
7070

71+
console.log(initialData);
72+
7173
return (
7274
<div>
7375
<div className="relative w-full h-[300px] lg:h-[400px]">

src/components/learning/RecentContents.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const RecentContents = ({ contents }: { contents: any[] }) => {
7575
<div
7676
key={index}
7777
className="border rounded-lg shadow-sm overflow-hidden flex flex-col bg-white cursor-pointer"
78-
onClick={() => router.push(`/learning/detail/${contents[0].articleId}`)}
78+
onClick={() => router.push(`/learning/detail/${content.articleId}`)}
7979
>
8080
<div className="relative w-full h-[180px]">
8181
<Image

src/components/learning/article/ArticleDetailClientWrapper.tsx

+43-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import ArticleDetailContent from '@/components/learning/article/ArticleDetailCon
77
import { getArticleDetail } from '@/factory/Article/GetArticle';
88
import { ArticleDetail } from '@/types/learning';
99
import { useAuthStore } from '@/store/userAuth.store';
10+
import Image from 'next/image';
11+
import sharedIcon from '../../../../public/images/learning/articleShare.svg';
12+
import likedIcon from '../../../../public/images/learning/articleLiked.svg';
13+
import RelatedArticles from './RelatedArticles';
1014

1115
interface ArticleDetailClientWrapperProps {
1216
articleId: number;
@@ -40,6 +44,16 @@ const ArticleDetailClientWrapper = ({ articleId }: ArticleDetailClientWrapperPro
4044
return null;
4145
}
4246

47+
const handleShare = async () => {
48+
const shareUrl = `${window.location.origin}/learning/detail/${articleId}`;
49+
try {
50+
await navigator.clipboard.writeText(shareUrl);
51+
alert('URL이 복사되었습니다!');
52+
} catch (error) {
53+
console.error('URL 복사 중 오류 발생:', error);
54+
}
55+
};
56+
4357
return (
4458
<div>
4559
<ArticleDetailHeader
@@ -49,14 +63,41 @@ const ArticleDetailClientWrapper = ({ articleId }: ArticleDetailClientWrapperPro
4963
authorName={data.authorName}
5064
imgLink={data.imgLink}
5165
/>
52-
<div className="max-w-[1000px] mx-auto my-6 text-sm text-gray-600">
53-
학습하기 &gt; 아티클 &gt; {data.categoryName}
66+
{/* todo: 화면 크기 작아지면 텍스트 영역 겹치는 부분 */}
67+
<div className="w-[90%] mx-auto py-12 flex items-center justify-between border-b border-[#ececec]">
68+
<div className="text-sm text-[#a0a0a0]">
69+
학습하기 &gt; 아티클 &gt; {data.categoryName}
70+
</div>
71+
<div className="absolute inset-x-0 text-center">
72+
<h1 className="text-lg font-semibold text-[#777777]">{data.title}</h1>
73+
</div>
74+
<div className="flex items-center gap-4 z-[9999]">
75+
<Image
76+
src={likedIcon}
77+
alt="좋아요 아이콘"
78+
width={24}
79+
height={24}
80+
className="cursor-pointer"
81+
/>
82+
<button onClick={handleShare} aria-label="공유하기">
83+
<Image
84+
src={sharedIcon}
85+
alt="공유 아이콘"
86+
width={24}
87+
height={24}
88+
className="cursor-pointer"
89+
/>
90+
</button>
91+
</div>
5492
</div>
5593
<ArticleDetailContent
5694
text={data.text}
95+
title={data.title}
5796
createdAt={data.createdAt}
5897
authorName={data.authorName}
98+
imgLink={data.imgLink}
5999
/>
100+
<RelatedArticles articleId={articleId} />
60101
</div>
61102
);
62103
};
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
1+
import { getValidImageSrc } from "@/utils/checkImageProperty";
2+
import Image from "next/image";
3+
14
interface ContentProps {
2-
text: string;
3-
createdAt: string;
4-
authorName: string;
5-
}
6-
7-
const ArticleDetailContent = ({ text, createdAt, authorName }: ContentProps) => {
8-
return (
9-
<div className="max-w-[800px] mx-auto my-10">
10-
<p className="text-sm text-gray-600">
11-
{new Date(createdAt).toLocaleDateString()} <br />
12-
BY. {authorName}
13-
</p>
14-
<article className="mt-8 text-gray-800 leading-relaxed">{text}</article>
15-
</div>
16-
);
17-
};
18-
19-
export default ArticleDetailContent;
5+
text: string;
6+
title: string;
7+
createdAt: string;
8+
authorName: string;
9+
imgLink: string | null;
10+
}
11+
12+
const ArticleDetailContent = ({ text, title, createdAt, authorName, imgLink }: ContentProps) => {
13+
// 텍스트를 줄 바꿈에 따라 분리
14+
const formattedText = text.split("\n\n");
15+
16+
return (
17+
<div className="max-w-[1000px] w-[90%] lg:w-full mx-auto my-10">
18+
<p className="text-sm text-gray-600">
19+
{new Date(createdAt).toLocaleDateString()} <br />
20+
BY. {authorName}
21+
</p>
22+
<Image
23+
src={getValidImageSrc(imgLink)}
24+
alt={title}
25+
className="w-full h-full mt-16 my-8"
26+
/>
27+
<article className="mt-8 text-black text-[22px] font-semibold font-['Pretendard'] leading-[30.80px] tracking-wide">
28+
{formattedText.map((paragraph, index) => (
29+
<p key={index} className="mb-4">
30+
{paragraph}
31+
</p>
32+
))}
33+
</article>
34+
</div>
35+
);
36+
};
37+
38+
export default ArticleDetailContent;

src/components/learning/article/ArticleDetailHeader.tsx

+6-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Image from 'next/image';
22
import defaultImage from '../../../../public/images/learning/learning_img.svg';
3+
import { getValidImageSrc } from '@/utils/checkImageProperty';
34

45
interface HeaderProps {
56
categoryName: string;
@@ -9,30 +10,21 @@ interface HeaderProps {
910
imgLink: string | null;
1011
}
1112

12-
const getValidImageSrc = (imgLink: string | null) => {
13-
if (!imgLink || imgLink === '테스트') {
14-
return defaultImage;
15-
}
16-
if (imgLink.startsWith('http://') || imgLink.startsWith('https://')) {
17-
return imgLink;
18-
}
19-
return defaultImage;
20-
};
21-
2213
const ArticleDetailHeader = ({ categoryName, title, createdAt, authorName, imgLink }: HeaderProps) => {
2314
return (
24-
<div className="relative w-full h-[500px]">
15+
<div className="relative w-full h-[300px]">
2516
<Image
2617
src={getValidImageSrc(imgLink)}
2718
alt={title}
2819
layout="fill"
2920
objectFit="cover"
3021
className="w-full h-full"
3122
/>
23+
{/* todo: 모바일 작업 */}
3224
<div className="absolute inset-0 bg-black/50 flex flex-col justify-center items-center text-white">
33-
<p className="text-sm">{categoryName}</p>
34-
<hr className="w-16 border-t-2 border-white my-2" />
35-
<h1 className="text-3xl font-bold">{title}</h1>
25+
<p className="text-center text-[#fffffc]/80 text-2xl font-medium font-['Pretendard'] leading-[33.60px] ">{categoryName}</p>
26+
<hr className="w-16 border-t-2 border-white my-4" />
27+
<h1 className="text-center text-white text-[40px] font-bold font-['Pretendard'] leading-[56px]">{title}</h1>
3628
</div>
3729
</div>
3830
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useEffect, useState } from "react";
2+
import { RelatedArticle } from "@/types/learning";
3+
import Image from "next/image";
4+
import { getRelatedArticles } from "@/factory/Article/GetArticle";
5+
import { getValidImageSrc } from "@/utils/checkImageProperty";
6+
7+
interface RelatedArticlesProps {
8+
articleId: number;
9+
}
10+
11+
const RelatedArticles = ({ articleId }: RelatedArticlesProps) => {
12+
const [relatedArticles, setRelatedArticles] = useState<RelatedArticle[]>([]);
13+
14+
useEffect(() => {
15+
const fetchData = async () => {
16+
try {
17+
const data = await getRelatedArticles(articleId);
18+
setRelatedArticles(data);
19+
} catch (error) {
20+
console.error("Failed to fetch related articles:", error);
21+
}
22+
};
23+
24+
fetchData();
25+
}, [articleId]);
26+
27+
return (
28+
<div className="max-w-[1000px] w-[90%] mx-auto mt-16 my-8">
29+
<h2 className="text-black text-[32px] font-bold font-['Pretendard'] leading-[44.80px] tracking-wide mb-8">관련 콘텐츠</h2>
30+
<div className="grid grid-cols-3 gap-4">
31+
{relatedArticles.map((item) => (
32+
<div
33+
key={item.article.articleId}
34+
className="flex flex-col justify-between h-full"
35+
>
36+
<div
37+
className="w-full relative overflow-hidden rounded-[25px]"
38+
style={{ aspectRatio: "409 / 255" }}
39+
>
40+
<Image
41+
src={getValidImageSrc(item.article.imgLink)}
42+
alt={item.article.title}
43+
layout="fill"
44+
objectFit="cover"
45+
/>
46+
</div>
47+
<div className="flex flex-col flex-1 mt-4">
48+
<h3 className="text-xl font-bold text-black mb-4">{item.article.title}</h3>
49+
<div className="mt-auto flex justify-between items-center text-[#8a8a8a] text-base font-semibold">
50+
<span>{new Date(item.article.createdAt).toLocaleDateString()}</span>
51+
<div className="flex items-center gap-2">
52+
<span className="text-[#8a8a8a] text-base font-semibold">❤️ {item.article.likes}</span>
53+
<span className="text-[#8a8a8a] text-base font-semibold">👁️ {item.article.views}</span>
54+
</div>
55+
</div>
56+
</div>
57+
</div>
58+
))}
59+
</div>
60+
</div>
61+
);
62+
};
63+
64+
export default RelatedArticles;

src/factory/Article/GetArticle.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { axiosInstance } from "@/service/axiosInstance";
2-
import { ArticleDetail } from "@/types/learning";
2+
import { ArticleDetail, RelatedArticle } from "@/types/learning";
33

44
export const getArticleDetail = async (articleId: number): Promise<ArticleDetail | null> => {
55
console.log(articleId);
@@ -14,4 +14,16 @@ export const getArticleDetail = async (articleId: number): Promise<ArticleDetail
1414
}
1515
throw error;
1616
}
17-
};
17+
};
18+
19+
export const getRelatedArticles = async (articleId: number): Promise<RelatedArticle[]> => {
20+
try {
21+
const response = await axiosInstance.get<RelatedArticle[]>(
22+
`articles/detail/${articleId}/related`
23+
);
24+
return response.data;
25+
} catch (error) {
26+
console.error('Failed to fetch related articles:', error);
27+
throw error;
28+
}
29+
};

src/types/learning.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,19 @@ export interface Article {
2929
views: number;
3030
likes: number;
3131
createdAt: string;
32-
}
32+
}
33+
34+
// 아티클 관련 정보
35+
export interface RelatedArticle {
36+
likedByMe: boolean;
37+
article: RelatedArticleContent;
38+
}
39+
40+
export interface RelatedArticleContent {
41+
articleId: number;
42+
title: string;
43+
imgLink: string | null;
44+
createdAt: string;
45+
views: number;
46+
likes: number;
47+
}

0 commit comments

Comments
 (0)