Item.tsx 10 KB

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