view.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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 } 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. if (res.data != null) {
  48. setData(res.data);
  49. }
  50. }).catch(err => {
  51. setError(err.message);
  52. }).finally(() => {
  53. setLoading(false);
  54. });
  55. }, [page, sort, _board.boardMeta.comment?.perPage, _post.id, setError]);
  56. useEffect(() => {
  57. loadComments();
  58. }, [loadComments]);
  59. const handleReply = useCallback((commentID: number) => {
  60. if (!loginCheck()) {
  61. return;
  62. }
  63. setReplyTargetID((prev) => (prev === commentID ? null : commentID)); // toggle
  64. }, [loginCheck]);
  65. const handleSuccess = useCallback((comment?: CommentItem) => {
  66. setReplyTargetID(null);
  67. if (!comment) {
  68. loadComments();
  69. return;
  70. }
  71. setData(prev => {
  72. // 수정: 기존 데이터에 ID가 있으면 교체
  73. const isTopEdit = prev.list.some(c => c.id === comment.id);
  74. const isChildEdit = prev.list.some(c => c.children.some(ch => ch.id === comment.id));
  75. if (isTopEdit) {
  76. return { ...prev, list: prev.list.map(c =>
  77. c.id === comment.id ? { ...comment, children: c.children } : c
  78. )};
  79. }
  80. if (isChildEdit) {
  81. return { ...prev, list: prev.list.map(c => ({
  82. ...c,
  83. children: c.children.map(ch => ch.id === comment.id ? comment : ch)
  84. }))};
  85. }
  86. // 새 답글
  87. if (comment.parentID) {
  88. return { ...prev, list: prev.list.map(c =>
  89. c.id === comment.parentID
  90. ? { ...c, children: [...c.children, comment], replies: c.replies + 1 }
  91. : c
  92. )};
  93. }
  94. // 새 최상위 댓글 (맨 앞 추가)
  95. return { total: prev.total + 1, totalRoots: prev.totalRoots + 1, list: [comment, ...prev.list] };
  96. });
  97. }, [loadComments]);
  98. const handleDelete = useCallback((commentID: number, parentID?: number) => {
  99. setReplyTargetID(null);
  100. setData(prev => {
  101. if (parentID) {
  102. return { ...prev, list: prev.list.map(c =>
  103. c.id === parentID ? { ...c, children: c.children.filter(ch => ch.id !== commentID), replies: Math.max(0, c.replies - 1) } : c
  104. )};
  105. }
  106. return { total: Math.max(0, prev.total - 1), totalRoots: Math.max(0, prev.totalRoots - 1), list: prev.list.filter(c => c.id !== commentID) };
  107. });
  108. }, []);
  109. const handleSortChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
  110. setSort(Number(e.target.value) as CommentSort);
  111. }, []);
  112. if (!_board.boardMeta.comment.enableComment) {
  113. return null;
  114. }
  115. return (
  116. <>
  117. {/* 댓글, 답글 */}
  118. <section id="comments">
  119. <div className='comment-header'>
  120. <article>댓글 <em>{data.total}개</em></article>
  121. {data.total > 0 && (
  122. <>
  123. <article>
  124. <select name="sort" title="정렬 기준" onChange={handleSortChange} value={sort}>
  125. <option value="0">최신순</option>
  126. <option value="1">인기순</option>
  127. </select>
  128. </article>
  129. <article>
  130. <button type='button' className="btn btn-default" title="새로고침" onClick={loadComments} disabled={loading}>
  131. <FontAwesomeIcon icon={faArrowRotateRight}/>
  132. </button>
  133. </article>
  134. </>
  135. )}
  136. </div>
  137. {canViewComment ? (
  138. <>
  139. {/* 댓글 작성란 */}
  140. {canWriteComment && (
  141. <WriteForm _board={_board} _post={_post} onSuccess={handleSuccess} />
  142. )}
  143. {/* QnA 답변 대기 안내 */}
  144. {_board.boardMeta.list.layout === BoardLayout.QnA && !_post.isReply && data.total === 0 ? (
  145. <div className="qna-pending-notice">
  146. <p>문의 내용 확인 중입니다.</p>
  147. <p>담당자 확인 후 답변 드리겠습니다.</p>
  148. </div>
  149. ) : (
  150. <>
  151. {/* 댓글 목록 */}
  152. <List _board={_board} _post={_post} _data={data} loading={loading} replyTargetID={replyTargetID} onReply={handleReply} onSuccess={handleSuccess} onDelete={handleDelete} canWriteReply={canWriteReply} />
  153. {/* 페이지네이션 */}
  154. <Pagination total={data.totalRoots} page={page} onChange={setPage} />
  155. </>
  156. )}
  157. </>
  158. ) : (
  159. <p className="text-center text-gray-500 py-10">댓글을 볼 수 있는 권한이 없습니다.</p>
  160. )}
  161. </section>
  162. </>
  163. );
  164. }