Item.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. 'use client';
  2. import '../style.scss';
  3. import Image from 'next/image';
  4. import { useState, useMemo, useCallback, useEffect, useRef, MouseEvent } from 'react';
  5. import Loading from '@/app/component/Loading';
  6. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
  7. import { faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag, faPenToSquare, faTrashCan } from '@fortawesome/free-regular-svg-icons';
  8. import { faThumbsUp as yThumbsUp, faThumbsDown as yThumbsDown, faFlag as yFlag, faEllipsisVertical, faLock } from '@fortawesome/free-solid-svg-icons';
  9. import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
  10. import { fetchApi, formatDate } from '@/lib/utils/client';
  11. import { isBoardAdmin } from '@/lib/utils/permission';
  12. import useAuth from '@/hooks/useAuth';
  13. import useErrorAlert from '@/hooks/useErrorAlert';
  14. import { type CommentItem } from '@/types/forum/comment';
  15. import { BoardResponse } from '@/types/response/forum/board';
  16. import { PostResponse } from '@/types/response/forum/post';
  17. import EditForm from './EditForm';
  18. import Report from '@/app/(main)/(forum)/post/_component/Report';
  19. import { Reaction } from '@/constants/forum';
  20. type Props = {
  21. _board: BoardResponse; // 게시판 정보
  22. _post: PostResponse; // 게시글 정보
  23. _comment: CommentItem; // 댓글 정보
  24. isReplying?: boolean; // 답글 버튼 클릭 여부
  25. onReply: () => void; // 답글 버튼 클릭 시
  26. onDelete: (commentID: number, parentID?: number) => void; // 삭제 후
  27. onSuccess: (comment?: CommentItem) => void; // 수정/삭제 후
  28. canWriteReply?: boolean; // 답글 작성 권한
  29. children?: React.ReactNode;
  30. }
  31. export default function Item({ _board, _post, _comment, isReplying, onReply, onSuccess, onDelete, canWriteReply, children } : Props)
  32. {
  33. const { member, loginCheck } = useAuth();
  34. const { setError } = useErrorAlert();
  35. const [loading, setLoading] = useState<boolean>(false);
  36. const [isEditing, setIsEditing] = useState<boolean>(false);
  37. const [hasLike, setHasLike] = useState<boolean>(_comment.hasLike);
  38. const [hasDisLike, setHasDisLike] = useState<boolean>(_comment.hasDislike);
  39. const [likes, setLikes] = useState<number>(_comment.likes);
  40. const [dislikes, setDislikes] = useState<number>(_comment.dislikes);
  41. const [hasReport, setHasReport] = useState<boolean>(_comment.hasReport);
  42. const [report, setReport] = useState<boolean>(false);
  43. const isOwner = member?.id === _comment.memberID;
  44. const isPostOwner = member?.id === _post.memberID;
  45. const canManage = isOwner || isBoardAdmin(_board.boardManager, member);
  46. const canViewSecret = !_comment.isSecret || isOwner || isPostOwner || canManage;
  47. const contentRef = useRef<HTMLSpanElement>(null);
  48. const writerThumb = useMemo(() => _comment.writer.thumbnail ?? '/resources/thumb.gif', [_comment.writer.thumbnail]);
  49. const writerIcon = useMemo(() => _comment.writer.icon ?? _comment.writer.gradeImage ?? null, [_comment.writer.icon, _comment.writer.gradeImage]);
  50. const writerName = useMemo(() => _comment.writer.name || _comment.writer.sid, [_comment.writer.name, _comment.writer.sid]);
  51. const createdAt = useMemo(() => formatDate(_comment.createdAt), [_comment.createdAt]);
  52. // 댓글 내 파일 다운로드 클릭 핸들러
  53. useEffect(() => {
  54. const el = contentRef.current;
  55. if (!el) {
  56. return;
  57. }
  58. const handler = (e: globalThis.MouseEvent) => {
  59. const target = e.target as HTMLElement;
  60. const file = target.closest('section.file-embed') as HTMLElement;
  61. if (file && file.dataset.uuid) {
  62. window.location.href = `/api/forum/comment/file/${file.dataset.uuid}`;
  63. }
  64. };
  65. el.addEventListener('click', handler);
  66. return () => {
  67. el.removeEventListener('click', handler);
  68. };
  69. }, []);
  70. const handleStartEdit = useCallback(() => {
  71. if (!loginCheck()) {
  72. return;
  73. }
  74. if (!canManage) {
  75. alert('본인의 댓글만 수정할 수 있습니다.');
  76. return;
  77. }
  78. setIsEditing(true);
  79. }, [loginCheck, canManage]);
  80. const handleCancelEdit = useCallback(() => {
  81. setIsEditing(false);
  82. }, []);
  83. const handleEditSuccess = useCallback((comment?: CommentItem) => {
  84. setIsEditing(false);
  85. onSuccess(comment);
  86. }, [onSuccess]);
  87. // 좋아요/싫어요 (로딩 없이 즉시 반영)
  88. const handleReaction = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
  89. const reaction = Number(e.currentTarget.value);
  90. if (!loginCheck()) {
  91. return;
  92. }
  93. try {
  94. const res = await fetchApi('/api/forum/comments/' + _comment.id + '/reaction', { method: 'POST', body: { reaction } });
  95. if (res.success) {
  96. switch (reaction) {
  97. case Reaction.Like:
  98. setHasLike(prev => {
  99. setLikes(c => prev ? c - 1 : c + 1);
  100. return !prev;
  101. });
  102. setHasDisLike(prev => {
  103. if (prev) setDislikes(c => c - 1);
  104. return false;
  105. });
  106. break;
  107. case Reaction.Dislike:
  108. setHasDisLike(prev => {
  109. setDislikes(c => prev ? c - 1 : c + 1);
  110. return !prev;
  111. });
  112. setHasLike(prev => {
  113. if (prev) setLikes(c => c - 1);
  114. return false;
  115. });
  116. break;
  117. }
  118. }
  119. } catch (err) {
  120. if (err instanceof Error) {
  121. setError(err.message);
  122. }
  123. }
  124. }, [_comment.id, loginCheck, setError]);
  125. // 신고하기
  126. const handleReport = useCallback(() => {
  127. if (hasReport) {
  128. alert('이미 신고하셨습니다.');
  129. return;
  130. }
  131. if (!loginCheck()) {
  132. return;
  133. }
  134. setReport(prev => !prev);
  135. }, [loginCheck, hasReport]);
  136. // 댓글 삭제
  137. const handleDelete = useCallback(() => {
  138. if (!loginCheck()) {
  139. return;
  140. }
  141. if (!canManage) {
  142. alert('본인의 댓글만 삭제할 수 있습니다.');
  143. return;
  144. }
  145. setIsEditing(false);
  146. if (confirm("댓글을 삭제하시겠습니까?")) {
  147. setLoading(true);
  148. // 댓글 삭제 호출
  149. fetchApi('/api/forum/comments/' + _comment.id, { method: 'DELETE' }).then(() => {
  150. // 삭제 성공 시 해당 댓글 영역 삭제
  151. onDelete(_comment.id, _comment.parentID);
  152. }).catch(err => {
  153. setError(err.message);
  154. }).finally(() => {
  155. setLoading(false);
  156. });
  157. }
  158. }, [_comment.id, _comment.parentID, loginCheck, canManage, onDelete, setError]);
  159. return (
  160. <>
  161. {loading ? (
  162. <Loading type={2} />
  163. ) : (
  164. <li className={_comment.isSecret ? 'is-secret' : ''}>
  165. <Report isEnable={true} open={report} onChange={setReport} onComplete={setHasReport} postID={_post.id} commentID={_comment.id} memberID={member?.id} />
  166. {_board.boardMeta.comment.showMemberThumb && (
  167. <div>
  168. <Image src={writerThumb} alt={writerName} width={72} height={0} />
  169. </div>
  170. )}
  171. <div>
  172. <div>
  173. <ul>
  174. <li>
  175. {_board.boardMeta.comment.showMemberIcon && writerIcon && (
  176. <Image src={writerIcon} alt="" width={16} height={16} className="inline-block mr-1 align-middle" />
  177. )}
  178. <span>{writerName}</span>
  179. </li>
  180. <li><small>{createdAt}</small></li>
  181. </ul>
  182. </div>
  183. <div>
  184. <DropdownMenu>
  185. <DropdownMenuTrigger>
  186. <FontAwesomeIcon icon={faEllipsisVertical}/>
  187. </DropdownMenuTrigger>
  188. <DropdownMenuContent align='end'>
  189. {!canManage && (
  190. <DropdownMenuItem onClick={handleReport}>
  191. <FontAwesomeIcon icon={!hasReport ? nFlag : yFlag} className="mr-2"/> 신고
  192. </DropdownMenuItem>
  193. )}
  194. {canManage && (
  195. <>
  196. <DropdownMenuItem onClick={handleStartEdit}>
  197. <FontAwesomeIcon icon={faPenToSquare} className="mr-2"/> 수정
  198. </DropdownMenuItem>
  199. <DropdownMenuItem onClick={handleDelete}>
  200. <FontAwesomeIcon icon={faTrashCan} className="mr-2"/> 삭제
  201. </DropdownMenuItem>
  202. </>
  203. )}
  204. </DropdownMenuContent>
  205. </DropdownMenu>
  206. </div>
  207. <div>
  208. {!canViewSecret ? (
  209. <span className='text-[#999]'>
  210. <FontAwesomeIcon icon={faLock} className='pe-1'/>
  211. 비밀글입니다.
  212. </span>
  213. ) : isEditing ? (
  214. <EditForm
  215. _board={_board}
  216. _post={_post}
  217. _comment={_comment}
  218. onSuccess={handleEditSuccess}
  219. onCancel={handleCancelEdit}
  220. />
  221. ) : (
  222. <>
  223. {_comment.mention && (
  224. <span className="mention">{_comment.mention.rawHandle} </span>
  225. )}
  226. {_comment.isSecret && (
  227. <FontAwesomeIcon icon={faLock} className='pe-1 text-[#999]'/>
  228. )}
  229. <span ref={contentRef} dangerouslySetInnerHTML={{ __html: _comment.content }} />
  230. </>
  231. )}
  232. </div>
  233. <div>
  234. {!isEditing && canViewSecret && (
  235. <>
  236. {canWriteReply !== false && (
  237. <div>
  238. <button type="button" title="답글" onClick={onReply}>
  239. {isReplying ? '답글 접기' : '답글'}
  240. </button>
  241. </div>
  242. )}
  243. {_board.boardMeta.comment.allowLike && (
  244. <>
  245. <div>
  246. <button type="button" value={Reaction.Like} onClick={handleReaction} title='좋아요'>
  247. <FontAwesomeIcon icon={!hasLike ? nThumbsUp : yThumbsUp } />
  248. </button>
  249. </div>
  250. <div>
  251. <em>{likes}</em>
  252. </div>
  253. </>
  254. )}
  255. {_board.boardMeta.comment.allowDisLike && (
  256. <>
  257. <div>
  258. <button type="button" value={Reaction.Dislike} onClick={handleReaction} title='싫어요'>
  259. <FontAwesomeIcon icon={!hasDisLike ? nThumbsDown : yThumbsDown } />
  260. </button>
  261. </div>
  262. <div>
  263. <em>{dislikes}</em>
  264. </div>
  265. </>
  266. )}
  267. </>
  268. )}
  269. </div>
  270. {children}
  271. </div>
  272. </li>
  273. )}
  274. </>
  275. );
  276. }