view.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. 'use client';
  2. import './style.scss';
  3. import { useState, useEffect, useCallback } from 'react';
  4. import useErrorAlert from '@/hooks/useErrorAlert';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
  6. import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons';
  7. import { BoardResponse } from '@/types/response/forum/board';
  8. import { PostResponse } from '@/types/response/forum/post';
  9. import { CommentListResponse } from '@/types/response/forum/comment';
  10. import { type CommentItem } from '@/types/forum/comment';
  11. import { fetchApi, throwError } from '@/lib/utils/client';
  12. import { checkPermission } from '@/lib/utils/permission';
  13. import useAuth from '@/hooks/useAuth';
  14. import WriteForm from './_component/WriteForm';
  15. import List from './_component/List';
  16. import { type CommentSort, CommentConst, BoardLayout } from '@/constants/forum';
  17. import Pagination from '@/app/component/Pagination';
  18. type Props = {
  19. _board: BoardResponse;
  20. _post: PostResponse;
  21. }
  22. export default function View({ _board, _post } : Props)
  23. {
  24. const { member, loginCheck } = useAuth();
  25. const { setError } = useErrorAlert();
  26. // 댓글 권한 체크
  27. const { canViewComment, canWriteComment, canWriteReply } = checkPermission(_board.boardMeta, _board.boardManager, member);
  28. const [loading, setLoading] = useState<boolean>(false);
  29. const [page, setPage] = useState<number>(1);
  30. const [sort, setSort] = useState<CommentSort>(CommentConst.Sort.CreatedAt);
  31. const [data, setData] = useState<CommentListResponse>({
  32. total: 0,
  33. totalRoots: 0,
  34. list: []
  35. });
  36. const [replyTargetID, setReplyTargetID] = useState<number|null>(null);
  37. const loadComments = useCallback(() => {
  38. setLoading(true);
  39. const queryParams = new URLSearchParams();
  40. queryParams.set('page', String(page));
  41. queryParams.set('perPage', String(_board.boardMeta.comment?.perPage ?? 20));
  42. if (sort !== undefined) {
  43. queryParams.set('sort', String(sort));
  44. }
  45. // 댓글 목록 호출
  46. fetchApi<CommentListResponse>(`/api/forum/posts/${_post.id}/comments?${queryParams.toString()}`).then((res) => {
  47. throwError(res);
  48. if (res.data != null) {
  49. setData(res.data);
  50. }
  51. }).catch(err => {
  52. setError(err.message);
  53. }).finally(() => {
  54. setLoading(false);
  55. });
  56. }, [page, sort, _board.boardMeta.comment?.perPage, _post.id, setError]);
  57. useEffect(() => {
  58. loadComments();
  59. }, [loadComments]);
  60. const handleReply = useCallback((commentID: number) => {
  61. if (!loginCheck()) {
  62. return;
  63. }
  64. setReplyTargetID((prev) => (prev === commentID ? null : commentID)); // toggle
  65. }, [loginCheck]);
  66. const handleSuccess = useCallback((comment?: CommentItem) => {
  67. setReplyTargetID(null);
  68. if (!comment) {
  69. loadComments();
  70. return;
  71. }
  72. setData(prev => {
  73. // 수정: 기존 데이터에 ID가 있으면 교체
  74. const isTopEdit = prev.list.some(c => c.id === comment.id);
  75. const isChildEdit = prev.list.some(c => c.children.some(ch => ch.id === comment.id));
  76. if (isTopEdit) {
  77. return { ...prev, list: prev.list.map(c =>
  78. c.id === comment.id ? { ...comment, children: c.children } : c
  79. )};
  80. }
  81. if (isChildEdit) {
  82. return { ...prev, list: prev.list.map(c => ({
  83. ...c,
  84. children: c.children.map(ch => ch.id === comment.id ? comment : ch)
  85. }))};
  86. }
  87. // 새 답글
  88. if (comment.parentID) {
  89. return { ...prev, list: prev.list.map(c =>
  90. c.id === comment.parentID
  91. ? { ...c, children: [...c.children, comment], replies: c.replies + 1 }
  92. : c
  93. )};
  94. }
  95. // 새 최상위 댓글 (맨 앞 추가)
  96. return { total: prev.total + 1, totalRoots: prev.totalRoots + 1, list: [comment, ...prev.list] };
  97. });
  98. }, [loadComments]);
  99. const handleDelete = useCallback((commentID: number, parentID?: number) => {
  100. setReplyTargetID(null);
  101. setData(prev => {
  102. if (parentID) {
  103. return { ...prev, list: prev.list.map(c =>
  104. c.id === parentID ? { ...c, children: c.children.filter(ch => ch.id !== commentID), replies: Math.max(0, c.replies - 1) } : c
  105. )};
  106. }
  107. return { total: Math.max(0, prev.total - 1), totalRoots: Math.max(0, prev.totalRoots - 1), list: prev.list.filter(c => c.id !== commentID) };
  108. });
  109. }, []);
  110. const handleSortChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
  111. setSort(Number(e.target.value) as CommentSort);
  112. }, []);
  113. if (!_board.boardMeta.comment.enableComment) {
  114. return null;
  115. }
  116. return (
  117. <>
  118. {/* 댓글, 답글 */}
  119. <section id="comments">
  120. <div className='comment-header'>
  121. <article>댓글 <em>{data.total}개</em></article>
  122. {data.total > 0 && (
  123. <>
  124. <article>
  125. <select name="sort" title="정렬 기준" onChange={handleSortChange} value={sort}>
  126. <option value="0">최신순</option>
  127. <option value="1">인기순</option>
  128. </select>
  129. </article>
  130. <article>
  131. <button type='button' className="btn btn-default" title="새로고침" onClick={loadComments} disabled={loading}>
  132. <FontAwesomeIcon icon={faArrowRotateRight}/>
  133. </button>
  134. </article>
  135. </>
  136. )}
  137. </div>
  138. {canViewComment ? (
  139. <>
  140. {/* 댓글 작성란 */}
  141. {canWriteComment && (
  142. <WriteForm _board={_board} _post={_post} onSuccess={handleSuccess} />
  143. )}
  144. {/* QnA 답변 대기 안내 */}
  145. {_board.boardMeta.list.layout === BoardLayout.QnA && !_post.isReply && data.total === 0 ? (
  146. <div className="qna-pending-notice">
  147. <p>문의 내용 확인 중입니다.</p>
  148. <p>담당자 확인 후 답변 드리겠습니다.</p>
  149. </div>
  150. ) : (
  151. <>
  152. {/* 댓글 목록 */}
  153. <List _board={_board} _post={_post} _data={data} loading={loading} replyTargetID={replyTargetID} onReply={handleReply} onSuccess={handleSuccess} onDelete={handleDelete} canWriteReply={canWriteReply} />
  154. {/* 페이지네이션 */}
  155. <Pagination total={data.totalRoots} page={page} onChange={setPage} />
  156. </>
  157. )}
  158. </>
  159. ) : (
  160. <p className="text-center text-gray-500 py-10">댓글을 볼 수 있는 권한이 없습니다.</p>
  161. )}
  162. </section>
  163. </>
  164. );
  165. }