diff --git a/package-lock.json b/package-lock.json index 160cae9..fb3f356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4848,6 +4848,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", "engines": { "node": ">=12.20.0" }, diff --git a/src/assets/Rookie Ver.2.svg b/src/assets/Rookie Ver.2.svg new file mode 100644 index 0000000..273c919 --- /dev/null +++ b/src/assets/Rookie Ver.2.svg @@ -0,0 +1,5 @@ + + + + + diff --git "a/src/assets/\352\270\200\354\223\260\352\270\260 \354\210\230\354\240\225.svg" "b/src/assets/\352\270\200\354\223\260\352\270\260 \354\210\230\354\240\225.svg" new file mode 100644 index 0000000..f0af7aa --- /dev/null +++ "b/src/assets/\352\270\200\354\223\260\352\270\260 \354\210\230\354\240\225.svg" @@ -0,0 +1,3 @@ + + + diff --git "a/src/assets/\353\214\223\352\270\200.svg" "b/src/assets/\353\214\223\352\270\200.svg" new file mode 100644 index 0000000..b47b396 --- /dev/null +++ "b/src/assets/\353\214\223\352\270\200.svg" @@ -0,0 +1,3 @@ + + + diff --git "a/src/assets/\354\210\230\354\240\225\355\225\230\352\270\260.svg" "b/src/assets/\354\210\230\354\240\225\355\225\230\352\270\260.svg" new file mode 100644 index 0000000..a1e1520 --- /dev/null +++ "b/src/assets/\354\210\230\354\240\225\355\225\230\352\270\260.svg" @@ -0,0 +1,5 @@ + + + + + diff --git "a/src/assets/\354\213\253\354\226\264\354\232\224.svg" "b/src/assets/\354\213\253\354\226\264\354\232\224.svg" new file mode 100644 index 0000000..7daa7d9 --- /dev/null +++ "b/src/assets/\354\213\253\354\226\264\354\232\224.svg" @@ -0,0 +1,3 @@ + + + diff --git "a/src/assets/\354\242\213\354\225\204\354\232\224.svg" "b/src/assets/\354\242\213\354\225\204\354\232\224.svg" new file mode 100644 index 0000000..d54de26 --- /dev/null +++ "b/src/assets/\354\242\213\354\225\204\354\232\224.svg" @@ -0,0 +1,3 @@ + + + diff --git "a/src/assets/\355\224\204\353\241\234\355\225\204 \354\210\230\354\240\225.svg" "b/src/assets/\355\224\204\353\241\234\355\225\204 \354\210\230\354\240\225.svg" new file mode 100644 index 0000000..9c2e961 --- /dev/null +++ "b/src/assets/\355\224\204\353\241\234\355\225\204 \354\210\230\354\240\225.svg" @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/common/AppHeader.tsx b/src/components/common/AppHeader.tsx index 40b1776..1224855 100644 --- a/src/components/common/AppHeader.tsx +++ b/src/components/common/AppHeader.tsx @@ -86,7 +86,11 @@ function AppHeader() { placeholder="게시글 검색..." className="pl-10 h-11 bg-blue-50 border-blue-200 focus-visible:ring-blue-500" /> - diff --git a/src/pages/MyPage.tsx b/src/pages/MyPage.tsx index c8fa2f2..475721a 100644 --- a/src/pages/MyPage.tsx +++ b/src/pages/MyPage.tsx @@ -1,49 +1,182 @@ -// MyPage.tsx import { useState, useEffect } from 'react'; import { AppSidebar } from '@/components/common'; import { Button } from '@/components/ui/button'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { getMyProfile, getMyScraps, getPosts } from '@/services/api'; +import { Card, CardContent } from '@/components/ui/card'; +import { TopicCard } from '@/components/common/TopicCard'; -const PAGE_SIZE = 4; +import { + getMyProfile, + getMyScraps, + getPosts, + getMyComments, +} from '@/services/api'; + +import RookieBadge from '@/assets/Rookie Ver.2.svg'; +import EditFieldIcon from '@/assets/글쓰기 수정.svg'; +import EditProfileIcon from '@/assets/프로필 수정.svg'; + +type Tab = 'posts' | 'comments' | 'saved'; + +// 등급 테두리 색상 +type TrustLevel = + | 'basic' + | 'active' + | 'trusted' + | 'model' + | 'top' + | 'legend' + | 'warning' + | 'danger'; + +interface PostReactions { + likeCount?: number; + dislikeCount?: number; + commentCount?: number; + myReaction?: 'LIKE' | 'DISLIKE' | null; +} + +type AuthorLike = { + id?: number | string; + userId?: number | string; + displayName?: string; + nickname?: string; +}; +interface PostItem { + id: number; + title: string; + createdAt: string; + content?: string; + tags?: string[]; + reactions?: PostReactions; + author?: AuthorLike | string; + [key: string]: unknown; +} + +interface MyComment { + id: string; + postId: string; + content: string; + status: string; + createdAt: string; + updatedAt: string; + reactions?: { + likeCount?: number; + dislikeCount?: number; + myReaction?: 'LIKE' | 'DISLIKE' | null; + }; + author: { + id: string; + displayName: string; + email: string; + nicknameColor: string; + }; +} + +interface Profile { + id: number; + nickname: string; + displayName?: string; + nicknameColor?: string; + major?: string; + bio?: string; + plan?: string; + intro?: string; + trustLevel?: TrustLevel; + [key: string]: unknown; +} + +const hasNameProperty = (val: unknown): val is { name: string } => { + return ( + typeof val === 'object' && + val !== null && + 'name' in val && + typeof (val as { name: unknown }).name === 'string' + ); +}; + +const normalizeValue = (val: unknown): string => { + if (val == null) return '-'; + if (hasNameProperty(val)) return val.name; + if (typeof val === 'object') return JSON.stringify(val); + return String(val); +}; + +const normalizeTags = (tags: unknown): string[] => { + if (!Array.isArray(tags)) return []; + return tags.map((tag) => { + if (hasNameProperty(tag)) return tag.name; + if (typeof tag === 'object' && tag !== null) return JSON.stringify(tag); + return String(tag); + }); +}; + +const formatDate = (value?: string) => { + if (!value) return '-'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return '-'; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}.${m}.${day}`; +}; + +const getTrustRingClass = (trustLevel?: TrustLevel): string => { + switch (trustLevel) { + case 'basic': + return 'border-[#e5e7eb] bg-white'; + case 'active': + return 'border-[#4b5563] bg-white'; + case 'trusted': + return 'border-[#22c55e] bg-white'; + case 'model': + return 'border-[#2563eb] bg-white'; + case 'top': + return 'border-[#a855f7] bg-white'; + case 'legend': + return 'border-[#facc15] bg-white'; + case 'warning': + return 'border-[#f97316] bg-white'; + case 'danger': + return 'border-[#ef4444] bg-white'; + default: + // 기본값은 파란색으로 두었음 + return 'border-[#2563eb] bg-white'; + } +}; export default function MyPage() { - const [activeTab, setActiveTab] = useState('posts'); - const [profile, setProfile] = useState(null); + // 상단 탭 상태 (내가 쓴 글 / 댓글 / 스크랩) + const [activeTab, setActiveTab] = useState('posts'); + + // 프로필 상태 + const [profile, setProfile] = useState(null); const [profileLoading, setProfileLoading] = useState(true); - const [myPosts, setMyPosts] = useState([]); + // 내가 쓴 글 목록 + const [myPosts, setMyPosts] = useState([]); const [postsLoading, setPostsLoading] = useState(true); - const [myScraps, setMyScraps] = useState([]); + // 내가 스크랩한 글 목록 + const [myScraps, setMyScraps] = useState([]); const [scrapsLoading, setScrapsLoading] = useState(true); - // 페이지네이션 상태 - const [postPage, setPostPage] = useState(1); - const [scrapPage, setScrapPage] = useState(1); - - // ✅ 문자열 변환 유틸 - const normalizeValue = (val: any) => { - if (val == null) return '-'; - if (typeof val === 'object') return val.name ?? JSON.stringify(val); - return val; - }; + // 내가 남긴 댓글 목록 상태 + const [myComments, setMyComments] = useState([]); + const [commentsLoading, setCommentsLoading] = useState(true); - // ✅ 태그 변환 - const normalizeTags = (tags: any[]) => - Array.isArray(tags) - ? tags.map((tag) => - typeof tag === 'object' ? tag.name ?? JSON.stringify(tag) : tag - ) - : []; + // 프로필 인라인 편집 모드 상태 + const [isEditingProfile, setIsEditingProfile] = useState(false); + const [editName, setEditName] = useState(''); + const [editBio, setEditBio] = useState(''); + // 프로필 정보 불러오기 useEffect(() => { + setProfileLoading(true); getMyProfile() - .then((data) => { - const normalized = { + .then((data: Profile) => { + // 서버에서 오는 값들을 화면용 문자열로 정규화 + const normalized: Profile = { ...data, nicknameColor: normalizeValue(data.nicknameColor), major: normalizeValue(data.major), @@ -56,297 +189,350 @@ export default function MyPage() { .finally(() => setProfileLoading(false)); }, []); + // 화면에 보여줄 이름 (displayName 우선, 없으면 nickname) + const profileName = + (profile?.displayName && profile.displayName !== '-') || + (profile?.nickname && profile.nickname !== '-') + ? profile?.displayName || profile?.nickname || '닉네임' + : '닉네임'; + + // 화면에 보여줄 소개 문구 (없으면 기본 문구) + const profileBio = + profile && profile.bio && profile.bio !== '-' + ? profile.bio + : '소개 문구가 없습니다.'; + + // 프로필 데이터가 바뀔 때 인라인 편집 인풋 초기값 동기화 + useEffect(() => { + setEditName(profileName); + setEditBio(profileBio); + }, [profileName, profileBio]); + + // 내가 쓴 글 목록 불러오기 useEffect(() => { setPostsLoading(true); getPosts() - .then((data) => { - const normalizedPosts = data.map((p: any) => ({ + .then((data: PostItem[]) => { + const normalizedPosts: PostItem[] = data.map((p) => ({ ...p, tags: normalizeTags(p.tags), })); setMyPosts(normalizedPosts); - setPostPage(1); // 데이터 갱신 시 1페이지로 }) .finally(() => setPostsLoading(false)); }, []); + // 스크랩한 글 목록 불러오기 useEffect(() => { setScrapsLoading(true); getMyScraps() - .then((data) => { - const normalizedScraps = data.map((p: any) => ({ + .then((data: PostItem[]) => { + const normalizedScraps: PostItem[] = data.map((p) => ({ ...p, tags: normalizeTags(p.tags), })); setMyScraps(normalizedScraps); - setScrapPage(1); // 데이터 갱신 시 1페이지로 }) .finally(() => setScrapsLoading(false)); }, []); - // 페이지네이션 계산 - const postTotalPages = Math.max( - 1, - Math.ceil(myPosts.length / PAGE_SIZE) - ); - const postStart = (postPage - 1) * PAGE_SIZE; - const pagedPosts = myPosts.slice(postStart, postStart + PAGE_SIZE); + // 내가 쓴 댓글 조회 (추후 페이징/무한스크롤 생기면 이쪽에서 로직 확장) + useEffect(() => { + setCommentsLoading(true); + getMyComments() + .then((data: MyComment[]) => { + setMyComments(data); + }) + .finally(() => setCommentsLoading(false)); + }, []); - const scrapTotalPages = Math.max( - 1, - Math.ceil(myScraps.length / PAGE_SIZE) - ); - const scrapStart = (scrapPage - 1) * PAGE_SIZE; - const pagedScraps = myScraps.slice(scrapStart, scrapStart + PAGE_SIZE); + // 각 탭별 카운트 + const postsCount = myPosts.length; + const commentsCount = myComments.length; + const scrapCount = myScraps.length; + + // 프로필 이니셜 (이름 없을 때 대비해서 '유' 기본값) + const profileInitial = + profileName && profileName.length > 0 ? profileName[0] : '유'; + + // 등급에 따른 아바타 테두리 클래스 + const gradeRingClass = getTrustRingClass(profile?.trustLevel); + + // 프로필 수정 버튼 토글 + 저장 로직 + const handleToggleEditProfile = () => { + if (isEditingProfile) { + // TODO: API 붙이면 여기서 PATCH 호출해서 프로필 업데이트 + setProfile((prev) => + prev + ? { + ...prev, + displayName: editName, + bio: editBio, + } + : prev, + ); + } + setIsEditingProfile((prev) => !prev); + }; + + // TopicCard에 내려줄 author 정보 정규화 + const getPostAuthor = ( + post: PostItem, + ): string | { id: string; displayName: string } => { + const rawAuthor = post.author; + + if (!rawAuthor) return profileName; + + if (typeof rawAuthor === 'string') { + return rawAuthor; + } + + const id = rawAuthor.id ?? rawAuthor.userId ?? ''; + const displayName = rawAuthor.displayName ?? rawAuthor.nickname ?? profileName; + + return { + id: String(id), + displayName, + }; + }; + + // 공통 글 카드 렌더러 (내 글 + 스크랩에서 재사용) + const renderPostCard = (post: PostItem) => { + const rawContent = + (post.content as string | undefined) ?? + (normalizeValue(post['content']) === '-' + ? '' + : normalizeValue(post['content'])); + + const contentText = rawContent ?? ''; + + return ( + + ); + }; + + // 내가 쓴 댓글 카드 (postId 기준으로 원본 글 상세로 이동) + const renderCommentCard = (comment: MyComment) => { + return ( + + ); + }; return (
-
+
- {/* 프로필 카드 */} - - + {/* 상단 프로필 영역 */} + + {profileLoading ? ( -
로딩 중…
+
프로필 불러오는 중…
) : profile ? ( -
- - - {profile.displayName ? profile.displayName[0] : '유'} - - -
-

- {normalizeValue(profile.displayName)} -

-

- {normalizeValue(profile.bio)} -

-
- {profile.major && ( - - 전공자: {normalizeValue(profile.major)} - - )} + <> +
+ {/* 프로필 이니셜 + 등급 링 */} +
+ {profileInitial}
-
- 이메일 - {profile.email ?? '-'} + +
+ {!isEditingProfile ? ( + <> +

+ {profileName} +

+

{profileBio}

+ + ) : ( +
+ {/* 닉네임 인라인 편집 인풋 */} +
+
+ + {editName || '닉네임'} + + setEditName(e.target.value)} + aria-label="닉네임 수정" + placeholder="닉네임" + size={1} + className="col-start-1 row-start-1 w-full min-w-0 bg-transparent border-none outline-none text-2xl font-bold text-[#9CA3AF] placeholder:text-[#d1d5db] px-1" + /> +
+ 닉네임 수정 아이콘 +
+ + {/* 소개 문구 인라인 편집 인풋 */} +
+
+ + {editBio || '소개 문구가 없습니다.'} + + setEditBio(e.target.value)} + aria-label="소개 문구 수정" + placeholder="소개 문구가 없습니다." + size={1} + className="col-start-1 row-start-1 w-full min-w-0 bg-transparent border-none outline-none text-sm text-[#9CA3AF] placeholder:text-[#d1d5db]" + /> +
+ 소개 수정 +
+
+ )} + +
+ Rookie Badge +
- -
+ + {/* 프로필 수정 토글 버튼 */} + + ) : ( -
+
프로필 정보를 불러올 수 없습니다.
)} - + - {/* 탭 영역 */} - - - 내가 쓴 글 - 내가 쓴 댓글 - 스크랩 - - - {/* 내가 쓴 글 */} - - {postsLoading ? ( -
- 로딩 중… -
- ) : pagedPosts.length > 0 ? ( - <> -
- {pagedPosts.map((post) => ( - - -

- {normalizeValue(post.title)} -

-
- {post.tags.map((tag: string, i: number) => ( - - {normalizeValue(tag)} - - ))} -
-
- - {post.createdAt - ? new Date(post.createdAt).toLocaleDateString( - 'ko-KR' - ) - : '-'} - - 👍 {post.reactions?.likeCount ?? 0} - 👎 {post.reactions?.dislikeCount ?? 0} -
-
-
- ))} -
+ {/* 가운데 카드: 탭 + 목록 영역 */} + + + setActiveTab(val as Tab)} + className="w-full" + > + + + 내가 쓴 글 ({postsCount}) + + + + 내가 쓴 댓글 ({commentsCount}) + + + + 스크랩 ({scrapCount}) + + - {/* 내가 쓴 글 페이지네이션 */} - {postTotalPages > 1 && ( -
- - {Array.from({ length: postTotalPages }).map((_, idx) => { - const p = idx + 1; - return ( - - ); - })} - + {/* 내가 쓴 글 탭 */} + + {postsLoading ? ( +
+ 로딩 중… +
+ ) : myPosts.length === 0 ? ( +
+ 작성한 글이 없습니다. +
+ ) : ( +
+ {myPosts.map((post) => renderPostCard(post))}
)} - - ) : ( -
- 작성한 글이 없습니다. -
- )} -
- - {/* 내가 쓴 댓글 - 아직 구현 안 되어 있으니 그대로 둠 */} - -
- 댓글 목록 기능은 아직 준비 중입니다. -
-
- - {/* 스크랩 */} - - {scrapsLoading ? ( -
- 로딩 중… -
- ) : pagedScraps.length > 0 ? ( - <> -
- {pagedScraps.map((post) => ( - - -

- {normalizeValue(post.title)} -

-
- {post.tags.map((tag: string, i: number) => ( - - {normalizeValue(tag)} - - ))} -
-
- - {post.createdAt - ? new Date(post.createdAt).toLocaleDateString( - 'ko-KR' - ) - : '-'} - - 👍 {post.reactions?.likeCount ?? 0} - 👎 {post.reactions?.dislikeCount ?? 0} -
-
-
- ))} -
+
- {/* 스크랩 페이지네이션 */} - {scrapTotalPages > 1 && ( -
- - {Array.from({ length: scrapTotalPages }).map( - (_, idx) => { - const p = idx + 1; - return ( - - ); - } - )} - + {/* 내가 쓴 댓글 탭 */} + + {commentsLoading ? ( +
+ 로딩 중… +
+ ) : myComments.length === 0 ? ( +
+ 작성한 댓글이 없습니다. +
+ ) : ( +
+ {myComments.map((comment) => renderCommentCard(comment))}
)} - - ) : ( -
- 스크랩한 글이 없습니다. -
- )} -
- + + + {/* 스크랩 탭 */} + + {scrapsLoading ? ( +
+ 로딩 중… +
+ ) : myScraps.length === 0 ? ( +
+ 스크랩한 글이 없습니다. +
+ ) : ( +
+ {myScraps.map((post) => renderPostCard(post))} +
+ )} +
+ + +
diff --git a/src/pages/PostDetail.tsx b/src/pages/PostDetail.tsx index f09a375..9d8ca7e 100644 --- a/src/pages/PostDetail.tsx +++ b/src/pages/PostDetail.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; // 1. useEffect 추가 import { useParams, useNavigate } from 'react-router-dom'; +// 1. AppSidebar import 경로를 상대 경로로 수정합니다. import { AppSidebar } from '../components/common'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; @@ -7,162 +8,137 @@ import { Badge } from '@/components/ui/badge'; import { Textarea } from '@/components/ui/textarea'; import { ThumbsUp, MessageCircle, ArrowLeft } from 'lucide-react'; import { Separator } from '@/components/ui/separator'; -import { getPost, getPostComments, createComment } from '@/services/api'; -import { togglePostLike, togglePostDislike, togglePostScrap, getIsScraped } from '@/services/api'; - -type ReactionSummary = { - likeCount: number; - dislikeCount: number; - myReaction?: 'LIKE' | 'DISLIKE' | null; -}; +// 2. 게시글과 댓글 데이터 타입을 정의합니다. interface PostData { id: string; - authorName: string; - authorInitial: string; + author: string; + authorInitial: string; // 아바타용 이니셜 + board: string; date: string; title: string; content: string; likes: number; + commentCount: number; // 댓글 수 tags: string[]; - reactions?: ReactionSummary; } interface CommentData { - id: string; - authorName: string; + id: number; + author: string; authorInitial: string; + board: string; date: string; content: string; likes: number; + replies: number; } -export default function PostDetail() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - - const [postData, setPostData] = useState(null); - const [comments, setComments] = useState([]); - const [commentInput, setCommentInput] = useState(''); - const [loading, setLoading] = useState(true); - const [isLiked, setIsLiked] = useState(false); - const [isDisliked, setIsDisliked] = useState(false); - const [isScraped, setIsScraped] = useState(false); +// 3. 가짜 데이터 (id에 따라 다른 내용을 반환하는 함수) +const fetchMockPostData = (postId: string | undefined): PostData | null => { + // 실제로는 여기서 axios.get(`/api/post/${postId}`) 등을 호출합니다. + if (postId === 'hot-1') { + return { + id: 'hot-1', + author: '커리어 전문가', + authorInitial: '커', + board: '취업/이직', + date: '2025.09.18', + title: '취업 준비생을 위한 면접 팁', + content: + '현직 면접관이 알려주는 실전 면접 노하우를 공유합니다. 예상 질문 리스트, 답변 구성 방법, 면접 시 주의해야 할 태도 등에 대해 자세히 다룹니다.\n\n추가적으로 궁금한 점이 있다면 댓글로 남겨주세요.', + likes: 152, + commentCount: 18, + tags: ['면접', '취업', '팁', '커리어'], + }; + } else if (postId === 'new-2') { + return { + id: 'new-2', + author: '개발 고수', + authorInitial: '개', + board: '프로그래밍', + date: '2025.09.18', + title: '프로그래밍 언어 선택 가이드', + content: + '2025년 기준, 어떤 프로그래밍 언어를 배워야 할까요? 각 언어의 특징과 전망, 그리고 학습 로드맵을 제시합니다. 웹 개발, 데이터 과학, 모바일 앱 등 분야별 추천 언어도 확인해보세요.', + likes: 88, + commentCount: 25, + tags: ['프로그래밍', '가이드', '개발', '언어'], + }; + } + // 다른 id 값이나 id가 없을 경우 (혹은 API 실패 시) + return null; +}; - const handleTagClick = (tag: string) => { - navigate(`/search?tag=${encodeURIComponent(tag)}`) +const fetchMockComments = (postId: string | undefined): CommentData[] => { + // 실제로는 axios.get(`/api/post/${postId}/comments`) 등을 호출합니다. + if (postId === 'hot-1') { + return [ + { + id: 1, + author: '취준생', + authorInitial: '취', + board: '취업/이직', + date: '2025.09.19', + content: '좋은 정보 감사합니다! 면접 때 꼭 참고하겠습니다.', + likes: 15, + replies: 0, + }, + { + id: 2, + author: '인사담당자', + authorInitial: '인', + board: '커리어', + date: '2025.09.20', + content: '핵심을 잘 짚어주셨네요. 특히 태도 부분이 중요합니다.', + likes: 22, + replies: 1, + }, + ]; } + return []; // 다른 게시글은 댓글 없음 (예시) +}; +export default function PostDetail() { + const { id } = useParams<{ id: string }>(); // URL 파라미터에서 id 가져오기 + const navigate = useNavigate(); + const [postData, setPostData] = useState(null); // 게시글 데이터 상태 + const [comments, setComments] = useState([]); // 댓글 목록 상태 + const [commentInput, setCommentInput] = useState(''); // 댓글 입력 상태 + + // 4. 컴포넌트 마운트 시 id에 맞는 데이터를 불러옵니다. useEffect(() => { - if (!id) { - setPostData(null); - setComments([]); - return; + const fetchedPost = fetchMockPostData(id); + const fetchedComments = fetchMockComments(id); + setPostData(fetchedPost); + setComments(fetchedComments); + }, [id]); // id가 변경될 때마다 데이터를 다시 불러옵니다. + + const handleCommentSubmit = () => { + if (commentInput.trim() && postData) { + const newComment: CommentData = { + id: comments.length + 1 + Date.now(), // 고유 ID 생성 (임시) + author: '현재 사용자', // 실제로는 로그인된 사용자 정보 사용 + authorInitial: '현', + board: postData.board, // 게시글과 같은 게시판으로 설정 + date: new Date() + .toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) + .replace(/\. /g, '.') + .replace('.', ''), + content: commentInput, + likes: 0, + replies: 0, + }; + setComments([...comments, newComment]); // 기존 댓글 목록에 새 댓글 추가 + setCommentInput(''); // 입력창 비우기 } + }; - const fetchData = async () => { - try { - const p = await getPost(id); - - // 작성자 이름/이니셜 - const authorName: string = - p?.author?.displayName ?? - p?.authorName ?? - p?.author?.name ?? - '작성자'; - const authorInitial = authorName.charAt(0); - - // 태그는 문자열 배열로 정규화 (백엔드가 {id,name} 형태일 수 있음) - const normalizedTags: string[] = Array.isArray(p?.tags) - ? p.tags.map((t: any) => (typeof t === 'string' ? t : t?.name)).filter(Boolean) - : []; - - // 리액션 요약에서 좋아요 수 추출 - const reactions: ReactionSummary | undefined = p?.reactions; - const likeCount = reactions?.likeCount ?? p?.likes ?? 0; - - const post: PostData = { - id: p.id, - authorName, - authorInitial, - date: p.createdAt - ? new Date(p.createdAt).toLocaleDateString('ko-KR') - : '-', - title: p.title ?? '', - content: p.content ?? '', - likes: likeCount, - tags: normalizedTags, - reactions, - }; - // 기존 setPostData(post); 바로 아래에 추가 - setPostData(post); - - // myReaction 초기값 적용 - if (p?.reactions?.myReaction === 'LIKE') { - setIsLiked(true); - setIsDisliked(false); - } else if (p?.reactions?.myReaction === 'DISLIKE') { - setIsLiked(false); - setIsDisliked(true); - } else { - setIsLiked(false); - setIsDisliked(false); - } - - try { - const scrapedStatus = await getIsScraped(id); - setIsScraped(!!scrapedStatus); - } catch { - setIsScraped(false); - } - - // 댓글 조회 및 매핑 - const c = await getPostComments(id); - const mapped: CommentData[] = Array.isArray(c) - ? c.map((it: any) => { - const cAuthorName: string = - it?.author?.displayName ?? - it?.authorName ?? - it?.author?.name ?? - '사용자'; - return { - id: it.id, - authorName: cAuthorName, - authorInitial: cAuthorName.charAt(0), - date: it.createdAt - ? new Date(it.createdAt).toLocaleDateString('ko-KR') - : '-', - content: it.body ?? it.content ?? '', - likes: it?.reactions?.likeCount ?? it?.likes ?? 0, - }; - }) - : []; - setComments(mapped); - } catch (e) { - setPostData(null); - setComments([]); - } finally { - setLoading(false); - } - }; - - setLoading(true); - fetchData(); - }, [id]); - - if (loading) { - return ( -
-
- -
-

불러오는 중…

-
-
-
- ); - } - + // 5. 데이터 로딩 중이거나 없을 경우 처리 if (!postData) { return (
@@ -170,7 +146,7 @@ export default function PostDetail() {

- 게시글을 찾을 수 없습니다. + 게시글을 불러오는 중이거나 찾을 수 없습니다.

- {/* 본문 */} + {/* 게시글 본문 (postData 사용) */}
+ {/* 6. 데이터에서 작성자 이니셜 사용 */} {postData.authorInitial}
-

{postData.authorName}

+ {/* 7. 데이터에서 작성자 이름 사용 */} +

{postData.author}

+ + {/* 8. 데이터에서 게시판 이름 사용 */} + {postData.board} +
-

{postData.date}

+

+ {/* 9. 데이터에서 날짜 사용 */} + {postData.date} +

+
-

{postData.title}

+

+ {/* 10. 데이터에서 제목 사용 */} + {postData.title} +

+ +
+ {/* 11. 데이터에서 본문 내용 사용 (줄바꿈 유지) */} + {postData.content} +
- {/* 태그 */} + {/* 12. 태그 표시 */}
{postData.tags.map((tag) => ( - {tag} + + {tag} + ))}
-
- {postData.content} -
-
- - - -
@@ -361,17 +246,22 @@ export default function PostDetail() {

댓글 작성

- + + {/* 현재 사용자 이니셜 (예시) */}현 +