import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createComment, deleteComment, getReplies, getRootComments, updateComment } from "@/features/comments/api/comment-api"; import { CommentForm } from "@/features/comments/components/CommentForm"; import { CommentItem } from "@/features/comments/components/CommentItem"; import type { CommentPage, CommentRecord, CommentTarget } from "@/features/comments/model/types"; import { ApiError } from "@/shared/api/api-error"; import type { ApiClient } from "@/shared/api/client"; import { PageState } from "@/shared/ui/page-state"; import { ResourcePagination } from "@/shared/ui/resource-pagination"; type LoadState = | { readonly status: "loading" } | { readonly data: CommentPage; readonly status: "content" } | { readonly message: string; readonly status: "error" }; type ReplyEntry = { readonly page: number; readonly state: LoadState; }; type ReplyState = Record; const pageSize = 20; function getErrorMessage(error: unknown): string { return error instanceof ApiError ? error.message : "댓글을 처리하지 못했습니다."; } function canEdit(comment: CommentRecord, target: CommentTarget): boolean { return comment.writerId === target.creatorId; } export function CommentThread({ apiClient, canMutate = true, target }: { readonly apiClient: ApiClient; readonly canMutate?: boolean; readonly target: CommentTarget }) { const audioContentId = target.kind === "audio" ? target.contentId : ""; const communityPostId = target.kind === "community" ? target.postId : ""; const requestTarget = useMemo(() => target.kind === "audio" ? { kind: "audio", characterId: target.characterId, contentId: audioContentId, creatorId: target.creatorId } : { kind: "community", characterId: target.characterId, postId: communityPostId, creatorId: target.creatorId }, [audioContentId, communityPostId, target.characterId, target.creatorId, target.kind]); const [errorMessage, setErrorMessage] = useState(null); const [expandedRootIds, setExpandedRootIds] = useState([]); const [isSaving, setIsSaving] = useState(false); const [rootPage, setRootPage] = useState(0); const [rootReloadKey, setRootReloadKey] = useState(0); const [roots, setRoots] = useState({ status: "loading" }); const [replies, setReplies] = useState({}); const isSavingRef = useRef(false); const loadReplies = useCallback(async function loadRepliesForPage(rootId: number, page = 0): Promise { setReplies((current) => ({ ...current, [rootId]: { page, state: { status: "loading" } } })); try { const data = await getReplies(apiClient, requestTarget, { commentId: rootId, page, size: pageSize }); if (page > 0 && data.items.length === 0 && data.totalCount <= page * pageSize) { await loadRepliesForPage(rootId, page - 1); return; } setReplies((current) => ({ ...current, [rootId]: { page, state: { data, status: "content" } } })); } catch (error: unknown) { setReplies((current) => ({ ...current, [rootId]: { page, state: { message: getErrorMessage(error), status: "error" } } })); } }, [apiClient, requestTarget]); useEffect(() => { let isCurrent = true; setRoots((current) => current.status === "content" ? current : { status: "loading" }); void getRootComments(apiClient, requestTarget, { page: rootPage, size: pageSize }) .then((data) => { if (isCurrent) { setRoots({ data, status: "content" }); } }) .catch((error: unknown) => { if (isCurrent) { setRoots({ message: getErrorMessage(error), status: "error" }); } }); return () => { isCurrent = false; }; }, [apiClient, requestTarget, rootPage, rootReloadKey]); async function runMutation(work: () => Promise, rootId?: number): Promise { if (isSavingRef.current) { return false; } isSavingRef.current = true; setIsSaving(true); setErrorMessage(null); try { await work(); setRootReloadKey((key) => key + 1); if (rootId !== undefined) { await loadReplies(rootId, replies[rootId]?.page ?? 0); } return true; } catch (error: unknown) { setErrorMessage(getErrorMessage(error)); return false; } finally { isSavingRef.current = false; setIsSaving(false); } } async function createRoot(comment: string): Promise { if (requestTarget.kind === "audio") { return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: null, isSecret: false, languageCode: null })); } return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: null, isSecret: false })); } async function createReply(rootId: number, comment: string): Promise { if (requestTarget.kind === "audio") { return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: rootId, isSecret: false, languageCode: null }), rootId); } return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: rootId, isSecret: false }), rootId); } function toggleReplies(rootId: number) { setExpandedRootIds((current) => current.includes(rootId) ? current.filter((id) => id !== rootId) : [...current, rootId]); if (replies[rootId] === undefined) { void loadReplies(rootId, 0); } } function renderReplies(root: CommentRecord) { if (!expandedRootIds.includes(root.id)) { return null; } const entry = replies[root.id] ?? { page: 0, state: { status: "loading" } }; const state = entry.state; if (state.status === "loading") { return ; } if (state.status === "error") { return void loadReplies(root.id)} state="error" title="답글 조회 실패" />; } return (
{canMutate ? createReply(root.id, comment)} submitLabel="답글 등록" /> : null} {state.data.items.map((reply) => ( void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: reply.id }), root.id)} onEdit={(comment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: reply.id, request: { comment } }), root.id)} /> ))} {state.data.totalCount > pageSize ? void loadReplies(root.id, page)} onSizeChange={() => void loadReplies(root.id, 0)} sizeOptions={[20]} /> : null}
); } return (

COMMENTS

댓글 관리

{canMutate ? : null} {errorMessage === null ? null :

{errorMessage}

} {isSaving ?

댓글을 저장하는 중

: null} {roots.status === "loading" ? : null} {roots.status === "error" ? setRootReloadKey((key) => key + 1)} state="error" title="댓글 조회 실패" /> : null} {roots.status === "content" ? ( <>

총 {roots.data.totalCount.toLocaleString("ko-KR")}개

{roots.data.totalCount === 0 ? : ( <>
{roots.data.items.map((comment) => (
void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} replyActionLabel={comment.replyCount > 0 || expandedRootIds.includes(comment.id) ? "답글 보기" : requestTarget.kind === "audio" && canMutate ? "답글 작성" : undefined} /> {renderReplies(comment)}
))}
setRootPage(0)} sizeOptions={[20]} /> )} ) : null}
); }