Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions apps/web/src/api/comments.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
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;
authorId: number;
content: string;
createdAt: string;
updatedAt: string;
authorInfo: AuthorInfoRes; // ✅ 추가
};

type CommentListRes = {
Expand All @@ -23,31 +30,38 @@ type CommentCreateRes = {
content: string;
createdAt: string;
updatedAt: string;
authorInfo: AuthorInfoRes; // ✅ 생성 응답에서도 내려온다고 가정
};

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 };

// ----- 매퍼 (서버 → 클라 엔티티) -----
// 주의: 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 -----
Expand Down Expand Up @@ -86,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,
};
}

Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/api/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToggleBookmarkRes>(
`/api/courses/${courseId}/bookmarks`,
);
return data;
}
4 changes: 3 additions & 1 deletion apps/web/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,15 @@ 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,
imageUrl: pickImageUrl(res),
createdAt: res.createdAt,
updatedAt: res.updatedAt,
authorInfo: res.authorInfo,
routeId: res.routeId ?? undefined,
};
}

Expand All @@ -141,6 +142,7 @@ function mapPostResToEntity(res: PostRes): Post {
createdAt: res.createdAt,
updatedAt: res.updatedAt,
authorInfo: res.authorInfo,
routeId: res.routeId ?? undefined,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Row key={c.id}>
<HeaderRow>
<Meta>
<Author>{c.author}</Author>
<Avatar src={avatar} alt="avatar" $placeholder={!avatar} />
<Author>{nickname}</Author>
</Meta>

{manageable && (onEdit || onDelete) && (
Expand Down Expand Up @@ -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;
`;
Expand All @@ -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};

Expand Down
124 changes: 107 additions & 17 deletions apps/web/src/pages/Community/CommunityDetail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -46,12 +47,35 @@ export default function CommunityDetail() {
null,
);

const [bookmarking, setBookmarking] = useState(false);
const [bookmarked, setBookmarked] = useState(false);

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 toStr = useCallback((v: unknown) => (v == null ? null : String(v)), []);
const myId = toStr(myIdRaw);
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;
Expand Down Expand Up @@ -140,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<string, unknown>;
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;
}

Expand All @@ -171,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);
}
Expand Down Expand Up @@ -269,9 +311,36 @@ export default function CommunityDetail() {
const imageUrl = post.imageUrl;
const hasMore = cCursor !== null;

const canEditComment = (c: Comment) => {
const cid = toStr(c.author) ?? null;
return myId !== null && cid !== null && 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 } });
};
const handleToggleBookmark = async () => {
if (!post || bookmarking) return;
if (post.category !== 'SHARE' || !post.routeId) return;

setBookmarking(true);
try {
const res = await toggleCourseBookmark(post.routeId);

if (res.bookmarked) {
setBookmarked(true);
sendToast('북마크했어요 ✅', 'success');
} else {
setBookmarked(false);
sendToast('북마크 해제했어요', 'success');
}
} catch (e) {
sendToast(getReadablePostError(e), 'error');
} finally {
setBookmarking(false);
}
};

return (
Expand All @@ -292,8 +361,15 @@ export default function CommunityDetail() {
<Article>{post.content || '내용이 없습니다.'}</Article>

<LikeBar>
{post.routeId && (
<BookmarkBtn onClick={handleToggleBookmark} disabled={bookmarking}>
<i
className={bookmarked ? 'ri-bookmark-fill' : 'ri-bookmark-line'}
/>
</BookmarkBtn>
)}
<LikeBtn onClick={handleToggleLike} disabled={liking}>
<i className="ri-thumb-up-fill" />{' '}
<i className="ri-thumb-up-fill" />
<span>{post.likeCount ?? 0}</span>
</LikeBtn>
</LikeBar>
Expand Down Expand Up @@ -414,3 +490,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;
}
`;
3 changes: 2 additions & 1 deletion apps/web/src/types/community.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading