diff --git a/apps/web/src/api/comments.ts b/apps/web/src/api/comments.ts index 535a3cf..d277bbe 100644 --- a/apps/web/src/api/comments.ts +++ b/apps/web/src/api/comments.ts @@ -1,18 +1,21 @@ import api from '@/lib/api'; import type { Comment } from '@/types/community'; -// ----- 서버 응답 타입 ----- -type AuthorObj = { id: number; nickname: string; avatarUrl?: string | null }; - +// ----- 서버 응답 타입 (커서 기반) ----- type CommentListItemRes = { id: number; postId: number; - author: AuthorObj; + authorId: number; content: string; createdAt: string; updatedAt: string; }; +type CommentListRes = { + items: CommentListItemRes[]; + nextCursor: string | null; +}; + type CommentCreateRes = { id: number; postId: number; @@ -22,13 +25,6 @@ type CommentCreateRes = { updatedAt: string; }; -type CommentListRes = { - items: CommentListItemRes[]; - page: number; - limit: number; - total: number; -}; - type CommentUpdateRes = { ok: boolean; comment: { id: number; content: string; updatedAt: string }; @@ -37,12 +33,11 @@ type CommentUpdateRes = { type OkRes = { ok: boolean }; // ----- 매퍼 (서버 → 클라 엔티티) ----- -// 프로젝트의 Comment 타입이 { id, postId, author, content } 기준이라면, -// createdAt/updatedAt은 필요 시 확장하세요. +// 주의: UI에서 c.author를 "작성자 ID"로 쓰는 흐름에 맞춰 문자열 ID로 매핑 const mapListItemToEntity = (r: CommentListItemRes): Comment => ({ id: String(r.id), postId: String(r.postId), - author: r.author?.nickname, + author: String(r.authorId), // ← authorId를 문자열로 content: r.content, updatedAt: r.updatedAt, }); @@ -50,26 +45,25 @@ const mapListItemToEntity = (r: CommentListItemRes): Comment => ({ const mapCreateItemToEntity = (r: CommentCreateRes): Comment => ({ id: String(r.id), postId: String(r.postId), - author: String(r.authorId), + author: String(r.authorId), // ← 동일하게 문자열 ID content: r.content, updatedAt: r.updatedAt, }); // ----- API ----- +// 커서 기반 목록 export async function getPostComments( postId: number | string, - page = 1, limit = 20, -): Promise<{ items: Comment[]; page: number; limit: number; total: number }> { + cursor?: string | null, +): Promise<{ items: Comment[]; nextCursor: string | null }> { const { data } = await api.get( `/community/posts/${postId}/comments`, - { params: { page, limit } }, + { params: { limit, cursor: cursor ?? undefined } }, ); return { items: data.items.map(mapListItemToEntity), - page: data.page, - limit: data.limit, - total: data.total, + nextCursor: data.nextCursor ?? null, }; } @@ -92,7 +86,6 @@ export async function updateComment( `/community/comments/${id}`, { content }, ); - console.log(data); return { id: String(data.comment.id), content: data.comment.content, diff --git a/apps/web/src/api/mypage.ts b/apps/web/src/api/mypage.ts index a4692f5..6ad4676 100644 --- a/apps/web/src/api/mypage.ts +++ b/apps/web/src/api/mypage.ts @@ -253,5 +253,5 @@ export function getReadableUserError(e: unknown): string { if (status === 404) return '사용자 정보를 찾을 수 없습니다.'; return '요청 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.'; } - return '알 수 없는 오류가 발생했습니다.'; + return `알 수 없는 오류가 발생했습니다.`; } diff --git a/apps/web/src/api/posts.ts b/apps/web/src/api/posts.ts index de69889..ac25eae 100644 --- a/apps/web/src/api/posts.ts +++ b/apps/web/src/api/posts.ts @@ -2,15 +2,13 @@ import axios from 'axios'; import api from '@/lib/api'; import type { Category, Post } from '@/types/community'; -/** 서버 enum (클라 Category에서 'ALL' 제외) */ -export type ServerPostType = Exclude; // 'FREE' | 'PROOF' | 'SHARE' | 'MATE' +export type ServerPostType = Exclude; -/** --- 요청/응답 타입: 서버 스펙 기준 --- */ export type CreatePostReq = { type: ServerPostType; title: string; content: string; - imageUrl: string; // 단일 이미지 URL + imageUrl: string; routeId?: number; }; @@ -18,7 +16,7 @@ export type UpdatePostReq = Partial<{ type: ServerPostType; title: string; content: string; - imageUrl: string | null; // 단일 이미지 URL 또는 null + imageUrl: string | null; routeId: number | null; }>; @@ -28,8 +26,8 @@ type PostResBase = { id: number; type: ServerPostType; title: string; - imageUrls?: string[]; // 서버가 과거 호환 위해 줄 수 있음 - imageUrl?: string; // 표준: 단일 + imageUrls?: string[]; + imageUrl?: string; routeId?: number | null; likeCount: number; commentCount: number; @@ -40,12 +38,12 @@ type PostResBase = { }; type PostResCreate = PostResBase & { - authorId: number; // 생성 응답 + authorId: number; content: string; }; type PostResDetail = PostResBase & { - author: AuthorObj; // 상세 응답 + author: AuthorObj; content: string; }; @@ -62,7 +60,7 @@ type UpdatePostRes = { type: ServerPostType; title: string; content: string; - imageUrl: string; // 서버 표준 + imageUrl: string; routeId?: number; updatedAt: string; }; @@ -77,18 +75,15 @@ export type GetPostsQuery = { limit?: number; }; -/** 목록 아이템 (author 객체 버전) */ type PostResListItem = PostResBase & { content?: string; // 서버가 줄 수도 있어 optional }; -/** 목록 아이템 (authorId만 있는 버전) */ type PostResListItemAlt = PostResBase & { authorId: number; content?: string; }; -/** 커서 목록 응답 */ type PostListCursorRes = { items: Array; nextCursor: number | string | null; @@ -103,7 +98,6 @@ export type GetPostsCursorQuery = { limit?: number; }; -/** 서버 응답에서 단일 imageUrl 계산 (imageUrl 우선, 없으면 imageUrls[0]) */ function pickImageUrl(res: { imageUrl?: string; imageUrls?: string[]; @@ -115,7 +109,6 @@ function pickImageUrl(res: { return ''; } -/** 목록 아이템 매퍼 (author/authorId 모두 대응) */ function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { const author = res.authorInfo?.nickname; @@ -134,9 +127,8 @@ function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { }; } -/** --- 서버 → 클라이언트 엔티티 매퍼 --- */ function mapPostResToEntity(res: PostRes): Post { - const author = res.authorInfo.nickname; + const author = res.authorInfo?.nickname ?? ''; const content = 'content' in res ? res.content : undefined; return { @@ -154,13 +146,11 @@ function mapPostResToEntity(res: PostRes): Post { }; } -/** --- 게시글 생성 --- */ export async function createPost(body: CreatePostReq): Promise { const { data } = await api.post('/community/posts', body); return mapPostResToEntity(data); } -/** --- 게시글 목록 --- */ export async function getPosts(query: GetPostsQuery = {}): Promise { const q: string[] = []; @@ -184,7 +174,6 @@ export async function getPosts(query: GetPostsQuery = {}): Promise { return (data.items ?? []).map(mapListItemToEntity); } -/** 커서 기반 목록 */ export async function getPostsCursor( query: GetPostsCursorQuery = {}, ): Promise<{ items: Post[]; nextCursor: number | string | null }> { @@ -214,13 +203,11 @@ export async function getPostsCursor( }; } -/** --- 게시글 상세 --- */ export async function getPost(id: number | string): Promise { const { data } = await api.get(`/community/posts/${id}`); return mapPostResToEntity(data); } -/** 게시글 수정 */ export async function updatePostById( id: number | string, body: UpdatePostReq, @@ -232,19 +219,16 @@ export async function updatePostById( return data; } -/** 좋아요 토글 */ export async function togglePostLike(id: number | string): Promise { const { data } = await api.post(`/community/posts/${id}/like`); return data; } -/** 게시글 삭제 (soft) */ export async function deletePostById(id: number | string): Promise { const { data } = await api.delete(`/community/posts/${id}`); return !!data?.ok; } -/** --- (선택) 에러 코드 헬퍼: 필요시 사용 --- */ export type PostErrorCode = | 'AUTH.UNAUTHORIZED' | 'COMMUNITY.POST_TYPE_INVALID' @@ -275,23 +259,16 @@ export function getReadablePostError(e: unknown): string { return '요청 처리에 실패했습니다. 잠시 후 다시 시도해주세요.'; } } - return '알 수 없는 오류가 발생했습니다.'; + return `알 수 없는 오류가 발생했습니다.`; } -/* ------------------------------------------- - * 🔽 PROOF 이미지 presign & 업로드 유틸 - * (아바타 presign 로직과 동일한 패턴) - * ----------------------------------------- */ - export type PresignReq = { - /** 예: 'community-proof' */ type: string; contentType: string; size: number; }; export type PresignRes = { - /** PUT 대상 URL (S3 presigned URL 등) */ url: string; }; @@ -304,19 +281,16 @@ export function objectUrlFromPresign(url: string): string { return url.split('?')[0]; } -/** 파일 업로드(put) 후 public URL(단일) 반환 */ export async function uploadProofWithFile(file: File): Promise { const contentType = file.type || 'image/jpeg'; const size = file.size; - // 1) presign const presign = await getProofPresign({ type: 'verify', contentType, size, }); - // 2) PUT 업로드 const putRes = await fetch(presign.url, { method: 'PUT', headers: { 'Content-Type': contentType }, @@ -326,6 +300,5 @@ export async function uploadProofWithFile(file: File): Promise { throw new Error(`이미지 업로드 실패: ${putRes.status}`); } - // 3) 업로드된 public URL 반환 return objectUrlFromPresign(presign.url); } diff --git a/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx b/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx index 61878cb..62deb95 100644 --- a/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/_components/CommentList.tsx @@ -21,31 +21,43 @@ export default function CommentList({ {comments.map((c) => { const manageable = canManage ? canManage(c) : true; const busy = workingId === c.id; + return ( - - {c.author} - - {c.content} - {manageable && (onEdit || onDelete) && ( - - + + + {c.author} + + + {manageable && (onEdit || onDelete) && ( + {onEdit && ( - onEdit(c.id)}> - 수정 - + onEdit(c.id)} + $variant="edit" + > + + )} {onDelete && ( - onDelete(c.id)} + $variant="delete" > - 삭제 - + + )} - - - )} + + )} + + + {c.content} ); })} @@ -61,11 +73,18 @@ const Wrap = styled.div` const Row = styled.div` display: grid; - gap: 6px; + gap: 8px; border-bottom: 1px solid ${({ theme }) => theme.colors.surface}; padding-bottom: 12px; `; +const HeaderRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; /* 좌: 작성자 / 우: 버튼들 */ + gap: 12px; +`; + const Meta = styled.div` font-size: 12px; color: ${({ theme }) => theme.colors.primary}; @@ -80,31 +99,28 @@ const Body = styled.div` white-space: pre-wrap; `; -/* 버튼 줄을 오른쪽 끝으로 */ -const BtnRow = styled.div` - display: flex; - justify-content: flex-end; -`; - -const Btns = styled.div` +const Actions = styled.div` display: flex; - gap: 8px; + align-items: end; + gap: 6px; `; -const baseBtn = ` - padding: 6px 10px; - border-radius: 999px; - border: 1px solid rgba(0, 0, 0, 0.08); +const IconButton = styled.button<{ $variant: 'edit' | 'delete' }>` + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; background: transparent; - font-weight: 600; -`; + font-size: 20px; -const EditButton = styled.button` - ${baseBtn} - color: ${({ theme }) => theme.colors.subtext}; -`; + /* 색상은 기존과 동일한 토큰 사용 */ + color: ${({ theme, $variant }) => + $variant === 'edit' ? theme.colors.primary : theme.colors.danger}; -const DeleteButton = styled.button` - ${baseBtn} - color: ${({ theme }) => theme.colors.danger}; + &[disabled] { + opacity: 0.6; + pointer-events: none; + } `; diff --git a/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx b/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx index d8331eb..7624d29 100644 --- a/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx @@ -12,10 +12,12 @@ export default function PostHeader({ post, onEdit, onDelete, + canEdit = false, }: { post: Post; onEdit: () => void; onDelete: () => void; + canEdit?: boolean; }) { return ( @@ -23,10 +25,12 @@ export default function PostHeader({
{CATEGORY_LABEL_MAP[post.category]}
- - 수정 - 삭제 - + {canEdit && ( + + 수정 + 삭제 + + )} {post.title} {post.authorInfo?.nickname} diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index 86a2fab..cd254e7 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -13,12 +13,13 @@ import { getReadablePostError, } from '@/api/posts'; import { - getPostComments, + getPostComments, // ← 커서 기반 시그니처: (postId, limit?, cursor?) createPostComment, updateComment, deleteComment, } from '@/api/comments'; import { useModal } from '@/components/common/modal/modalContext'; +import { useNativeBridgeStore } from '@/stores/nativeBridgeStore'; export default function CommunityDetail() { const nav = useNavigate(); @@ -32,21 +33,29 @@ export default function CommunityDetail() { const [error, setError] = useState(null); const [comments, setComments] = useState([]); - const [cPage, setCPage] = useState(1); const [cLimit] = useState(20); - const [cTotal, setCTotal] = useState(0); + const [cCursor, setCCursor] = useState(null); // ✅ 커서 const [cLoading, setCLoading] = useState(false); const [liking, setLiking] = useState(false); const [deleting, setDeleting] = useState(false); const [submittingComment, setSubmittingComment] = useState(false); - // 개별 댓글 작업 상태 const [editingId, setEditingId] = useState(null); const [deletingCommentId, setDeletingCommentId] = useState( null, ); + // 현재 사용자 ID (RN → Web 브리지) + const myIdRaw = 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; + + // ---- 게시글 로드 ---- useEffect(() => { let mounted = true; (async () => { @@ -72,17 +81,17 @@ export default function CommunityDetail() { }; }, [id]); + // ---- 댓글 최초 로드 (커서 기반) ---- useEffect(() => { let mounted = true; (async () => { if (!id) return; try { setCLoading(true); - const res = await getPostComments(id, 1, cLimit); + const { items, nextCursor } = await getPostComments(id, cLimit); if (mounted) { - setComments(res.items); - setCPage(res.page); - setCTotal(res.total); + setComments(items); + setCCursor(nextCursor ?? null); } } catch (e) { console.error('[comments] load error:', getReadablePostError(e)); @@ -95,6 +104,7 @@ export default function CommunityDetail() { }; }, [id, cLimit]); + // ---- 좋아요 ---- const handleToggleLike = async () => { if (!post || liking) return; setLiking(true); @@ -117,16 +127,19 @@ export default function CommunityDetail() { } }; + // ---- 댓글 작성 ---- const handleSubmitComment = async () => { const v = inputRef.current?.value?.trim(); if (!v || !id) return; try { setSubmittingComment(true); const created = await createPostComment(id, v); - setComments((prev) => [...prev, created]); // ASC 정렬 가정 → 뒤에 붙임 + setComments((prev) => [...prev, created]); // ASC 가정 → 뒤에 추가 inputRef.current!.value = ''; - setPost((p) => (p ? { ...p, commentsCount: p.commentsCount + 1 } : p)); - setCTotal((t) => t + 1); + setPost((p) => + p ? { ...p, commentsCount: (p.commentsCount ?? 0) + 1 } : p, + ); + // 커서는 서버가 새로 계산하므로 유지(append만 했으니 nextCursor 변화 없음) } catch (e) { alert(getReadablePostError(e)); } finally { @@ -134,6 +147,7 @@ export default function CommunityDetail() { } }; + // ---- 댓글 수정 ---- const handleEditComment = async (commentId: string) => { const target = comments.find((c) => c.id === commentId); if (!target) return; @@ -145,7 +159,7 @@ export default function CommunityDetail() { confirmText: '수정', cancelText: '취소', }); - if (next == null) return; // 취소 + if (next == null) return; const content = next.trim(); if (!content) { await alert({ @@ -160,7 +174,9 @@ export default function CommunityDetail() { const res = await updateComment(commentId, content); setComments((prev) => prev.map((c) => - c.id === commentId ? { ...c, content: res.content } : c, + c.id === commentId + ? { ...c, content: res.content, updatedAt: res.updatedAt } + : c, ), ); } catch (e) { @@ -170,6 +186,7 @@ export default function CommunityDetail() { } }; + // ---- 게시글 삭제 ---- const handleDeletePost = useCallback(async () => { if (!post || deleting) return; @@ -196,6 +213,7 @@ export default function CommunityDetail() { } }, [post, deleting, nav, confirm]); + // ---- 댓글 삭제 ---- const handleDeleteComment = async (commentId: string) => { const ok = await confirm({ title: '댓글을 삭제하시겠습니까?', @@ -211,9 +229,10 @@ export default function CommunityDetail() { if (done) { setComments((prev) => prev.filter((c) => c.id !== commentId)); setPost((p) => - p ? { ...p, commentsCount: Math.max(0, p.commentsCount - 1) } : p, + p + ? { ...p, commentsCount: Math.max(0, (p.commentsCount ?? 0) - 1) } + : p, ); - setCTotal((t) => Math.max(0, t - 1)); } else { await alert({ title: '실패', description: '삭제에 실패했습니다.' }); } @@ -224,15 +243,19 @@ export default function CommunityDetail() { } }; + // ---- 더 보기(커서 페이징) ---- const handleLoadMore = async () => { - if (!post) return; - const next = cPage + 1; - const res = await getPostComments(post.id, next, cLimit); - setComments((prev) => [...prev, ...res.items]); - setCPage(res.page); - setCTotal(res.total); + if (!post || !cCursor) return; // 더 불러올 것 없으면 종료 + const { items, nextCursor } = await getPostComments( + post.id, + cLimit, + cCursor, + ); + setComments((prev) => [...prev, ...items]); + setCCursor(nextCursor ?? null); }; + // ---- early returns ---- if (loading) { return ( @@ -256,7 +279,12 @@ export default function CommunityDetail() { } const imageUrl = post.imageUrl; - const hasMore = comments.length < cTotal; + const hasMore = cCursor !== null; + + const canEditComment = (c: Comment) => { + const cid = toStr(c.author) ?? null; + return myId !== null && cid !== null && myId === cid; + }; return ( @@ -264,6 +292,7 @@ export default function CommunityDetail() { post={post} onEdit={() => nav(`/community/edit/${post.id}`)} onDelete={handleDeletePost} + canEdit={canEdit} /> @@ -287,6 +316,7 @@ export default function CommunityDetail() { onEdit={handleEditComment} onDelete={handleDeleteComment} workingId={editingId ?? deletingCommentId ?? null} + canManage={canEditComment} /> {cLoading && 댓글 불러오는 중…}