From c01534507fff00539babffbc3afefe85ab10a4a7 Mon Sep 17 00:00:00 2001 From: FF Date: Tue, 16 Sep 2025 15:35:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20api=20?= =?UTF-8?q?=EB=B3=80=EB=8F=99=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지, 닉네임을 댓글에 적용 --- apps/web/src/api/comments.ts | 17 ++++++++++---- .../_components/CommentList.tsx | 22 ++++++++++++++++--- .../pages/Community/CommunityDetail/index.tsx | 7 +++--- apps/web/src/types/community.ts | 3 ++- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/web/src/api/comments.ts b/apps/web/src/api/comments.ts index d277bbe..1a59d64 100644 --- a/apps/web/src/api/comments.ts +++ b/apps/web/src/api/comments.ts @@ -1,7 +1,13 @@ import api from '@/lib/api'; import type { Comment } from '@/types/community'; -// ----- 서버 응답 타입 (커서 기반) ----- +// ----- 서버 응답 타입 ----- +type AuthorInfoRes = { + id: number; + nickname: string; + imageUrl: string | null; +}; + type CommentListItemRes = { id: number; postId: number; @@ -9,6 +15,7 @@ type CommentListItemRes = { content: string; createdAt: string; updatedAt: string; + authorInfo: AuthorInfoRes; // ✅ 추가 }; type CommentListRes = { @@ -23,6 +30,7 @@ type CommentCreateRes = { content: string; createdAt: string; updatedAt: string; + authorInfo: AuthorInfoRes; // ✅ 생성 응답에서도 내려온다고 가정 }; type CommentUpdateRes = { @@ -33,21 +41,22 @@ type CommentUpdateRes = { type OkRes = { ok: boolean }; // ----- 매퍼 (서버 → 클라 엔티티) ----- -// 주의: UI에서 c.author를 "작성자 ID"로 쓰는 흐름에 맞춰 문자열 ID로 매핑 const mapListItemToEntity = (r: CommentListItemRes): Comment => ({ id: String(r.id), postId: String(r.postId), - author: String(r.authorId), // ← authorId를 문자열로 + authorId: r.authorId, content: r.content, updatedAt: r.updatedAt, + authorInfo: r.authorInfo, }); const mapCreateItemToEntity = (r: CommentCreateRes): Comment => ({ id: String(r.id), postId: String(r.postId), - author: String(r.authorId), // ← 동일하게 문자열 ID + authorId: r.authorId, content: r.content, updatedAt: r.updatedAt, + authorInfo: r.authorInfo, }); // ----- API ----- diff --git a/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx b/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx index 62deb95..616639c 100644 --- a/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx @@ -22,11 +22,15 @@ export default function CommentList({ const manageable = canManage ? canManage(c) : true; const busy = workingId === c.id; + const nickname = c.authorInfo?.nickname ?? c.authorId ?? '알 수 없음'; + const avatar = c.authorInfo?.imageUrl ?? ''; + return ( - {c.author} + + {nickname} {manageable && (onEdit || onDelete) && ( @@ -81,15 +85,28 @@ const Row = styled.div` const HeaderRow = styled.div` display: flex; align-items: center; - justify-content: space-between; /* 좌: 작성자 / 우: 버튼들 */ + justify-content: space-between; gap: 12px; `; const Meta = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; font-size: 12px; color: ${({ theme }) => theme.colors.primary}; `; +const Avatar = styled.img<{ $placeholder?: boolean }>` + width: 28px; + height: 28px; + border-radius: 999px; + object-fit: cover; + background: ${({ theme, $placeholder }) => + $placeholder ? theme.colors.surfaceAlt : 'transparent'}; + border: 1px solid ${({ theme }) => theme.colors.border}; +`; + const Author = styled.span` font-weight: 600; `; @@ -115,7 +132,6 @@ const IconButton = styled.button<{ $variant: 'edit' | 'delete' }>` background: transparent; font-size: 20px; - /* 색상은 기존과 동일한 토큰 사용 */ color: ${({ theme, $variant }) => $variant === 'edit' ? theme.colors.primary : theme.colors.danger}; diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index 12dbb4e..5be3d13 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -46,9 +46,8 @@ export default function CommunityDetail() { null, ); - const myIdRaw = useNativeBridgeStore((s) => s.init?.user?.id ?? null); + const myId = useNativeBridgeStore((s) => s.init?.user?.id ?? null); const toStr = useCallback((v: unknown) => (v == null ? null : String(v)), []); - const myId = toStr(myIdRaw); const authorId = toStr(post?.authorInfo?.id) ?? null; const canEdit = myId !== null && authorId !== null && myId === authorId; @@ -270,8 +269,8 @@ export default function CommunityDetail() { const hasMore = cCursor !== null; const canEditComment = (c: Comment) => { - const cid = toStr(c.author) ?? null; - return myId !== null && cid !== null && myId === cid; + const cid = c.authorInfo?.id ?? c.authorId ?? null; + return myId !== null && cid !== null && Number(myId) === cid; }; return ( diff --git a/apps/web/src/types/community.ts b/apps/web/src/types/community.ts index 1688cc8..cea2257 100644 --- a/apps/web/src/types/community.ts +++ b/apps/web/src/types/community.ts @@ -22,9 +22,10 @@ export interface Post { export interface Comment { id: string; postId: string; - author: string; + authorId: number; content: string; updatedAt: string; + authorInfo?: AuthorObj; } export type NavKey = 'home' | 'photo' | 'route' | 'run'; From 23d5ec04e7e76c05c4044cabd15cd485da4c69c7 Mon Sep 17 00:00:00 2001 From: FF Date: Tue, 16 Sep 2025 16:25:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=BD=EB=A1=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/courses.ts | 11 ++++ apps/web/src/api/posts.ts | 4 +- .../pages/Community/CommunityDetail/index.tsx | 61 +++++++++++++++++- src/hooks/useWebViewMessenger.ts | 64 ++++++++++++++++--- 4 files changed, 130 insertions(+), 10 deletions(-) diff --git a/apps/web/src/api/courses.ts b/apps/web/src/api/courses.ts index e73e346..60faea2 100644 --- a/apps/web/src/api/courses.ts +++ b/apps/web/src/api/courses.ts @@ -41,3 +41,14 @@ export async function searchUserCourses(cursor?: string) { nextCursor: data.nextCursor ?? null, }; } + +export type ToggleBookmarkRes = { + bookmarked: boolean; +}; + +export async function toggleCourseBookmark(courseId: number | string) { + const { data } = await api.put( + `/api/courses/${courseId}/bookmarks`, + ); + return data; +} diff --git a/apps/web/src/api/posts.ts b/apps/web/src/api/posts.ts index 4409064..6903f62 100644 --- a/apps/web/src/api/posts.ts +++ b/apps/web/src/api/posts.ts @@ -114,7 +114,7 @@ function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { id: String(res.id), category: res.type, title: res.title, - author: 'author', + author: res.authorInfo?.nickname ?? '', commentsCount: res.commentCount, content: res.content, likeCount: res.likeCount, @@ -122,6 +122,7 @@ function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { createdAt: res.createdAt, updatedAt: res.updatedAt, authorInfo: res.authorInfo, + routeId: res.routeId ?? undefined, }; } @@ -141,6 +142,7 @@ function mapPostResToEntity(res: PostRes): Post { createdAt: res.createdAt, updatedAt: res.updatedAt, authorInfo: res.authorInfo, + routeId: res.routeId ?? undefined, }; } diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index 5be3d13..53005e2 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -20,6 +20,7 @@ import { } from '@/api/comments'; import { useModal } from '@/components/common/modal/modalContext'; import { useNativeBridgeStore } from '@/stores/nativeBridgeStore'; +import { toggleCourseBookmark } from '@/api/courses'; export default function CommunityDetail() { const nav = useNavigate(); @@ -46,6 +47,9 @@ export default function CommunityDetail() { null, ); + const [bookmarking, setBookmarking] = useState(false); + const [bookmarked, setBookmarked] = useState(false); + const myId = useNativeBridgeStore((s) => s.init?.user?.id ?? null); const toStr = useCallback((v: unknown) => (v == null ? null : String(v)), []); @@ -273,6 +277,40 @@ export default function CommunityDetail() { return myId !== null && cid !== null && Number(myId) === cid; }; + const postToNative = (evt: unknown) => { + window.ReactNativeWebView?.postMessage(JSON.stringify(evt)); + }; + + const sendToast = ( + message: string, + variant: 'success' | 'error' = 'success', + ) => { + postToNative({ type: 'toast', payload: { message, variant } }); + }; + console.log(post.routeId); + const handleToggleBookmark = async () => { + if (!post || bookmarking) return; + if (post.category !== 'SHARE' || !post.routeId) return; + + setBookmarking(true); + try { + const res = await toggleCourseBookmark(post.routeId); + setPost((p) => (p ? { ...p, bookmarked: res.bookmarked } : p)); + + if (res.bookmarked) { + setBookmarked(true); + sendToast('북마크했어요 ✅', 'success'); + } else { + setBookmarked(false); + sendToast('북마크 해제했어요', 'success'); + } + } catch (e) { + sendToast(getReadablePostError(e), 'error'); + } finally { + setBookmarking(false); + } + }; + return ( {post.content || '내용이 없습니다.'} + {post.routeId && ( + + + + )} - {' '} + {post.likeCount ?? 0} @@ -413,3 +458,17 @@ const Submit = styled.button` color: ${({ theme }) => theme.colors.surface}; border: 1px solid ${({ theme }) => theme.colors.border}; `; + +const BookmarkBtn = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: 999px; + background: ${({ theme }) => theme.colors.surfaceAlt}; + color: ${({ theme }) => theme.colors.subtext}; + &[disabled] { + opacity: 0.6; + pointer-events: none; + } +`; diff --git a/src/hooks/useWebViewMessenger.ts b/src/hooks/useWebViewMessenger.ts index d8487c8..d2f9442 100644 --- a/src/hooks/useWebViewMessenger.ts +++ b/src/hooks/useWebViewMessenger.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef } from 'react'; +import Toast from 'react-native-toast-message'; import WebView, { WebViewMessageEvent } from 'react-native-webview'; import { refreshToken } from '@/services/auth.service'; @@ -37,12 +38,28 @@ type WebToNativeMessage = | { type: 'NAVIGATE'; payload: { screen: NativeScreen; params?: Record }; + } + | { + type: 'TOAST' | 'toast'; + payload?: { message?: string; variant?: 'success' | 'error' | 'info' }; }; type UseWebViewMessengerOptions = { onNavigate?: (screen: NativeScreen, params?: Record) => void; }; +type MsgOf = Extract< + WebToNativeMessage, + { type: T } +>; + +function asMsg( + m: WebToNativeMessage, + t: T, +): MsgOf | null { + return m.type === t ? (m as MsgOf) : null; +} + export function useWebViewMessenger(opts?: UseWebViewMessengerOptions) { const webRef = useRef(null); @@ -99,12 +116,14 @@ export function useWebViewMessenger(opts?: UseWebViewMessengerOptions) { msg = { type: 'LOG', payload: e.nativeEvent.data }; } - switch (msg.type) { - case 'PING': + const typeLower = String(msg.type ?? '').toLowerCase(); + + switch (typeLower) { + case 'ping': postJson({ type: 'PONG' }); break; - case 'REFRESH_TOKEN': + case 'refresh_token': try { const { accessToken } = await refreshToken(); if (user) setAuth(accessToken, user); @@ -117,15 +136,20 @@ export function useWebViewMessenger(opts?: UseWebViewMessengerOptions) { } break; - case 'NAVIGATE': - opts?.onNavigate?.(msg.payload.screen, msg.payload.params); + case 'navigate': { + const p = (msg as Extract) + .payload ?? { screen: undefined, params: undefined }; + opts?.onNavigate?.(p.screen as NativeScreen, p.params); break; + } - case 'LOG': - console.log('[Web LOG]', msg.payload); + case 'log': { + const m = asMsg(msg, 'LOG'); + console.log('[Web LOG]', m?.payload); break; + } - case 'LOGOUT': { + case 'logout': { try { await signOut(); } catch (error) { @@ -140,7 +164,31 @@ export function useWebViewMessenger(opts?: UseWebViewMessengerOptions) { break; } + case 'toast': { + const p = + (msg as Extract) + .payload || {}; + const message = + typeof p.message === 'string' && p.message.trim() + ? p.message + : '알림'; + const variant = + p.variant === 'error' + ? 'error' + : p.variant === 'info' + ? 'info' + : 'success'; + + Toast.show({ + type: variant, // 'success' | 'error' | 'info' + text1: message, + }); + break; + } + default: + // 필요시 로그 + // console.log('[Web → RN] unhandled:', msg); break; } }, From 935bb415665ea60ba9f10179248a073e19579301 Mon Sep 17 00:00:00 2001 From: FF Date: Tue, 16 Sep 2025 16:50:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=83=80=EC=9E=85=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/comments.ts | 16 +++-- .../pages/Community/CommunityDetail/index.tsx | 71 ++++++++++++++----- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/apps/web/src/api/comments.ts b/apps/web/src/api/comments.ts index 1a59d64..ce54647 100644 --- a/apps/web/src/api/comments.ts +++ b/apps/web/src/api/comments.ts @@ -34,8 +34,13 @@ type CommentCreateRes = { }; type CommentUpdateRes = { - ok: boolean; - comment: { id: number; content: string; updatedAt: string }; + id: number; + postId: number; + authorId: number; + content: string; + createdAt: string; + updatedAt: string; + authorInfo: { id: number; nickname: string; imageUrl: string | null }; }; type OkRes = { ok: boolean }; @@ -95,10 +100,11 @@ export async function updateComment( `/community/comments/${id}`, { content }, ); + return { - id: String(data.comment.id), - content: data.comment.content, - updatedAt: data.comment.updatedAt, + id: String(data.id), + content: data.content, + updatedAt: data.updatedAt, }; } diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index 53005e2..2f802a8 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -50,11 +50,32 @@ export default function CommunityDetail() { const [bookmarking, setBookmarking] = useState(false); const [bookmarked, setBookmarked] = useState(false); - const myId = useNativeBridgeStore((s) => s.init?.user?.id ?? null); - const toStr = useCallback((v: unknown) => (v == null ? null : String(v)), []); + const toId = useCallback( + (v: unknown): string | null => (v == null ? null : String(v)), + [], + ); + const sameId = useCallback( + (a: unknown, b: unknown) => { + const as = toId(a); + const bs = toId(b); + return as !== null && bs !== null && as === bs; + }, + [toId], + ); + + const myIdRaw = useNativeBridgeStore((s) => s.init?.user?.id ?? null); + const myId = toId(myIdRaw); + const authorId = toId(post?.authorInfo?.id ?? post?.author ?? null); + + const canEdit = sameId(myId, authorId); - const authorId = toStr(post?.authorInfo?.id) ?? null; - const canEdit = myId !== null && authorId !== null && myId === authorId; + const canEditComment = useCallback( + (c: Comment) => { + const commentId = toId(c?.authorInfo?.id ?? c?.authorId ?? null); + return sameId(myId, commentId); + }, + [myId, sameId, toId], + ); useEffect(() => { let mounted = true; @@ -143,24 +164,41 @@ export default function CommunityDetail() { } }; + const getPromptText = (v: unknown): string => { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'object') { + const any = v as Record; + const cand = + (any.value as string | undefined) ?? + (any.text as string | undefined) ?? + (any.content as string | undefined); + if (typeof cand === 'string') return cand; + } + return String(v); + }; + const handleEditComment = async (commentId: string) => { const target = comments.find((c) => c.id === commentId); if (!target) return; - const next = await prompt({ + const raw = await prompt({ title: '댓글을 수정하세요', - defaultValue: target.content, + defaultValue: target.content ?? '', placeholder: '댓글 내용을 입력', confirmText: '수정', cancelText: '취소', }); - if (next == null) return; - const content = next.trim(); + if (raw == null) return; // 취소 + + const content = getPromptText(raw).trim(); + if (!content) { - await alert({ - title: '입력이 필요합니다', - description: '내용을 입력하세요.', - }); + sendToast('내용을 입력하세요.', 'error'); + return; + } + if (content === target.content) { + sendToast('변경된 내용이 없습니다.', 'error'); return; } @@ -174,8 +212,9 @@ export default function CommunityDetail() { : c, ), ); + sendToast('댓글을 수정했어요 ✅', 'success'); } catch (e) { - await alert({ title: '오류', description: getReadablePostError(e) }); + sendToast(getReadablePostError(e), 'error'); } finally { setEditingId(null); } @@ -272,11 +311,6 @@ export default function CommunityDetail() { const imageUrl = post.imageUrl; const hasMore = cCursor !== null; - const canEditComment = (c: Comment) => { - const cid = c.authorInfo?.id ?? c.authorId ?? null; - return myId !== null && cid !== null && Number(myId) === cid; - }; - const postToNative = (evt: unknown) => { window.ReactNativeWebView?.postMessage(JSON.stringify(evt)); }; @@ -295,7 +329,6 @@ export default function CommunityDetail() { setBookmarking(true); try { const res = await toggleCourseBookmark(post.routeId); - setPost((p) => (p ? { ...p, bookmarked: res.bookmarked } : p)); if (res.bookmarked) { setBookmarked(true); From 6cde72d276f3cf1a16d408cafc06f36898db8711 Mon Sep 17 00:00:00 2001 From: FF Date: Tue, 16 Sep 2025 16:52:29 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EC=BD=98=EC=86=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/Community/CommunityDetail/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index 2f802a8..af17a7a 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -321,7 +321,6 @@ export default function CommunityDetail() { ) => { postToNative({ type: 'toast', payload: { message, variant } }); }; - console.log(post.routeId); const handleToggleBookmark = async () => { if (!post || bookmarking) return; if (post.category !== 'SHARE' || !post.routeId) return;