| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- 'use client';
- import '../style.scss';
- import Image from 'next/image';
- import { useState, useMemo, useCallback, useEffect, useRef, MouseEvent } from 'react';
- import Loading from '@/app/component/Loading';
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
- import { faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag, faPenToSquare, faTrashCan } from '@fortawesome/free-regular-svg-icons';
- import { faThumbsUp as yThumbsUp, faThumbsDown as yThumbsDown, faFlag as yFlag, faEllipsisVertical, faLock } from '@fortawesome/free-solid-svg-icons';
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
- import { fetchApi, formatDate } from '@/lib/utils/client';
- import { isBoardAdmin } from '@/lib/utils/permission';
- import useAuth from '@/hooks/useAuth';
- import useErrorAlert from '@/hooks/useErrorAlert';
- import { type CommentItem } from '@/types/forum/comment';
- import { BoardResponse } from '@/types/response/forum/board';
- import { PostResponse } from '@/types/response/forum/post';
- import EditForm from './EditForm';
- import Report from '@/app/(main)/(forum)/post/_component/Report';
- import { Reaction } from '@/constants/forum';
- type Props = {
- _board: BoardResponse; // 게시판 정보
- _post: PostResponse; // 게시글 정보
- _comment: CommentItem; // 댓글 정보
- isReplying?: boolean; // 답글 버튼 클릭 여부
- onReply: () => void; // 답글 버튼 클릭 시
- onDelete: (commentID: number, parentID?: number) => void; // 삭제 후
- onSuccess: (comment?: CommentItem) => void; // 수정/삭제 후
- canWriteReply?: boolean; // 답글 작성 권한
- children?: React.ReactNode;
- }
- export default function Item({ _board, _post, _comment, isReplying, onReply, onSuccess, onDelete, canWriteReply, children } : Props)
- {
- const { member, loginCheck } = useAuth();
- const { setError } = useErrorAlert();
- const [loading, setLoading] = useState<boolean>(false);
- const [isEditing, setIsEditing] = useState<boolean>(false);
- const [hasLike, setHasLike] = useState<boolean>(_comment.hasLike);
- const [hasDisLike, setHasDisLike] = useState<boolean>(_comment.hasDislike);
- const [likes, setLikes] = useState<number>(_comment.likes);
- const [dislikes, setDislikes] = useState<number>(_comment.dislikes);
- const [hasReport, setHasReport] = useState<boolean>(_comment.hasReport);
- const [report, setReport] = useState<boolean>(false);
- const isOwner = member?.id === _comment.memberID;
- const isPostOwner = member?.id === _post.memberID;
- const canManage = isOwner || isBoardAdmin(_board.boardManager, member);
- const canViewSecret = !_comment.isSecret || isOwner || isPostOwner || canManage;
- const contentRef = useRef<HTMLSpanElement>(null);
- const writerThumb = useMemo(() => _comment.writer.thumbnail ?? '/resources/thumb.gif', [_comment.writer.thumbnail]);
- const writerIcon = useMemo(() => _comment.writer.icon ?? _comment.writer.gradeImage ?? null, [_comment.writer.icon, _comment.writer.gradeImage]);
- const writerName = useMemo(() => _comment.writer.name || _comment.writer.sid, [_comment.writer.name, _comment.writer.sid]);
- const createdAt = useMemo(() => formatDate(_comment.createdAt), [_comment.createdAt]);
- // 댓글 내 파일 다운로드 클릭 핸들러
- useEffect(() => {
- const el = contentRef.current;
- if (!el) {
- return;
- }
- const handler = (e: globalThis.MouseEvent) => {
- const target = e.target as HTMLElement;
- const file = target.closest('section.file-embed') as HTMLElement;
- if (file && file.dataset.uuid) {
- window.location.href = `/api/forum/comment/file/${file.dataset.uuid}`;
- }
- };
- el.addEventListener('click', handler);
- return () => {
- el.removeEventListener('click', handler);
- };
- }, []);
- const handleStartEdit = useCallback(() => {
- if (!loginCheck()) {
- return;
- }
- if (!canManage) {
- alert('본인의 댓글만 수정할 수 있습니다.');
- return;
- }
- setIsEditing(true);
- }, [loginCheck, canManage]);
- const handleCancelEdit = useCallback(() => {
- setIsEditing(false);
- }, []);
- const handleEditSuccess = useCallback((comment?: CommentItem) => {
- setIsEditing(false);
- onSuccess(comment);
- }, [onSuccess]);
- // 좋아요/싫어요 (로딩 없이 즉시 반영)
- const handleReaction = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
- const reaction = Number(e.currentTarget.value);
- if (!loginCheck()) {
- return;
- }
- try {
- const res = await fetchApi('/api/forum/comments/' + _comment.id + '/reaction', { method: 'POST', body: { reaction } });
- if (res.success) {
- switch (reaction) {
- case Reaction.Like:
- setHasLike(prev => {
- setLikes(c => prev ? c - 1 : c + 1);
- return !prev;
- });
- setHasDisLike(prev => {
- if (prev) setDislikes(c => c - 1);
- return false;
- });
- break;
- case Reaction.Dislike:
- setHasDisLike(prev => {
- setDislikes(c => prev ? c - 1 : c + 1);
- return !prev;
- });
- setHasLike(prev => {
- if (prev) setLikes(c => c - 1);
- return false;
- });
- break;
- }
- }
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message);
- }
- }
- }, [_comment.id, loginCheck, setError]);
- // 신고하기
- const handleReport = useCallback(() => {
- if (hasReport) {
- alert('이미 신고하셨습니다.');
- return;
- }
- if (!loginCheck()) {
- return;
- }
- setReport(prev => !prev);
- }, [loginCheck, hasReport]);
- // 댓글 삭제
- const handleDelete = useCallback(() => {
- if (!loginCheck()) {
- return;
- }
- if (!canManage) {
- alert('본인의 댓글만 삭제할 수 있습니다.');
- return;
- }
- setIsEditing(false);
- if (confirm("댓글을 삭제하시겠습니까?")) {
- setLoading(true);
- // 댓글 삭제 호출
- fetchApi('/api/forum/comments/' + _comment.id, { method: 'DELETE' }).then(() => {
- // 삭제 성공 시 해당 댓글 영역 삭제
- onDelete(_comment.id, _comment.parentID);
- }).catch(err => {
- setError(err.message);
- }).finally(() => {
- setLoading(false);
- });
- }
- }, [_comment.id, _comment.parentID, loginCheck, canManage, onDelete, setError]);
- return (
- <>
- {loading ? (
- <Loading type={2} />
- ) : (
- <li className={_comment.isSecret ? 'is-secret' : ''}>
- <Report isEnable={true} open={report} onChange={setReport} onComplete={setHasReport} postID={_post.id} commentID={_comment.id} memberID={member?.id} />
- {_board.boardMeta.comment.showMemberThumb && (
- <div>
- <Image src={writerThumb} alt={writerName} width={72} height={0} />
- </div>
- )}
- <div>
- <div>
- <ul>
- <li>
- {_board.boardMeta.comment.showMemberIcon && writerIcon && (
- <Image src={writerIcon} alt="" width={16} height={16} className="inline-block mr-1 align-middle" />
- )}
- <span>{writerName}</span>
- </li>
- <li><small>{createdAt}</small></li>
- </ul>
- </div>
- <div>
- <DropdownMenu>
- <DropdownMenuTrigger>
- <FontAwesomeIcon icon={faEllipsisVertical}/>
- </DropdownMenuTrigger>
- <DropdownMenuContent align='end'>
- {!canManage && (
- <DropdownMenuItem onClick={handleReport}>
- <FontAwesomeIcon icon={!hasReport ? nFlag : yFlag} className="mr-2"/> 신고
- </DropdownMenuItem>
- )}
- {canManage && (
- <>
- <DropdownMenuItem onClick={handleStartEdit}>
- <FontAwesomeIcon icon={faPenToSquare} className="mr-2"/> 수정
- </DropdownMenuItem>
- <DropdownMenuItem onClick={handleDelete}>
- <FontAwesomeIcon icon={faTrashCan} className="mr-2"/> 삭제
- </DropdownMenuItem>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- <div>
- {!canViewSecret ? (
- <span className='text-[#999]'>
- <FontAwesomeIcon icon={faLock} className='pe-1'/>
- 비밀글입니다.
- </span>
- ) : isEditing ? (
- <EditForm
- _board={_board}
- _post={_post}
- _comment={_comment}
- onSuccess={handleEditSuccess}
- onCancel={handleCancelEdit}
- />
- ) : (
- <>
- {_comment.mention && (
- <span className="mention">{_comment.mention.rawHandle} </span>
- )}
- {_comment.isSecret && (
- <FontAwesomeIcon icon={faLock} className='pe-1 text-[#999]'/>
- )}
- <span ref={contentRef} dangerouslySetInnerHTML={{ __html: _comment.content }} />
- </>
- )}
- </div>
- <div>
- {!isEditing && canViewSecret && (
- <>
- {canWriteReply !== false && (
- <div>
- <button type="button" title="답글" onClick={onReply}>
- {isReplying ? '답글 접기' : '답글'}
- </button>
- </div>
- )}
- {_board.boardMeta.comment.allowLike && (
- <>
- <div>
- <button type="button" value={Reaction.Like} onClick={handleReaction} title='좋아요'>
- <FontAwesomeIcon icon={!hasLike ? nThumbsUp : yThumbsUp } />
- </button>
- </div>
- <div>
- <em>{likes}</em>
- </div>
- </>
- )}
- {_board.boardMeta.comment.allowDisLike && (
- <>
- <div>
- <button type="button" value={Reaction.Dislike} onClick={handleReaction} title='싫어요'>
- <FontAwesomeIcon icon={!hasDisLike ? nThumbsDown : yThumbsDown } />
- </button>
- </div>
- <div>
- <em>{dislikes}</em>
- </div>
- </>
- )}
- </>
- )}
- </div>
- {children}
- </div>
- </li>
- )}
- </>
- );
- }
|