'use client'; import '../style.scss'; import Image from 'next/image'; import { useState, useRef, useEffect, useCallback } from 'react'; import Editor, { type Handle as EditorHandle } from '@/app/(main)/(forum)/post/_component/Editor'; import EmojiPicker from '@/app/component/EmojiPicker'; import Loading from '@/app/component/Loading'; import { useMemberContext } from '@/contexts/memberProvider'; import useErrorAlert from '@/hooks/useErrorAlert'; import { BoardResponse } from '@/types/response/forum/board'; import { PostResponse } from '@/types/response/forum/post'; import { CommentCreateRequest } from '@/types/request/forum/comment'; import { CommentCreateResponse } from '@/types/response/forum/comment'; import { CommentItem } from '@/types/forum/comment'; import { BoardLayout, CommentConst } from '@/constants/forum'; import { fetchApi } from '@/lib/utils/client'; import useAuth from '@/hooks/useAuth'; import MentionSuggestion from './MentionSuggestion'; type Props = { _board: BoardResponse, _post: PostResponse, _comment?: CommentItem; // null이면 새 댓글, 값이 있으면 답글 onSuccess: (comment?: CommentItem) => void; onCancel?: () => void; }; export default function WriteForm({ _board, _post, _comment, onSuccess, onCancel }: Props) { const { member } = useMemberContext(); const { loginCheck } = useAuth(); const boardMeta = _board.boardMeta; const { setError } = useErrorAlert(); const [loading, setLoading] = useState(false); const [form, setForm] = useState({ postID: _post.id, parentID: _comment ? (_comment.parentID ?? _comment.id) : undefined, mention: '', content: '', isSecret: false }); const textareaRef = useRef(null); const editorRef = useRef(null); const initialContent = useCallback(() => { if (_comment) { // YouTube 스타일: 항상 최상위 댓글 아래에 붙임 const resolvedParentID = _comment.parentID ?? _comment.id; const stored = localStorage.getItem('member'); const myID = stored ? JSON.parse(stored).id : null; if (_comment.parentID && myID !== _comment.writer.id) { // 대댓글 + 다른 사용자 > 답글 대상의 작성자를 멘션 const writerHandle = _comment.writer.name || _comment.writer.sid; const mentionText = `@${writerHandle} `; setForm(prev => ({ ...prev, parentID: resolvedParentID, mention: `@${writerHandle}`, content: mentionText })); setTimeout(() => textareaRef.current?.focus(), 0); } else { // 최상위 댓글 답글 또는 본인 댓글 답글 setForm(prev => ({ ...prev, parentID: resolvedParentID, mention: '', content: '' })); } } else { // 새 댓글 setForm(prev => ({ ...prev, parentID: undefined, mention: '', content: '' })); } }, [_comment]); useEffect(() => { initialContent(); }, [initialContent]); // 입력 창 높이 조절 const resizeTextarea = useCallback(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = 'auto'; // 초기화 textarea.style.height = `${textarea.scrollHeight}px`; // 높이 조정 } }, []); useEffect(() => { resizeTextarea(); }, [form.content, resizeTextarea]); // 멘션 자동완성 선택 const handleMentionSelect = useCallback((newContent: string) => { setForm(prev => ({ ...prev, content: newContent })); }, []); // 비밀글 체크 const handleSecretChange = useCallback((e: React.ChangeEvent) => { setForm(prev => ({ ...prev, isSecret: e.target.checked })); }, []); // 이모지 선택 const handleEmoji = useCallback((emoji: string) => { setForm(prev => ({ ...prev, content: prev.content + emoji })); }, []); // 입력 내용 저장 const handleContentChange = useCallback((e: React.ChangeEvent) => { setForm(prev => ({ ...prev, content: e.target.value })); }, []); // 입력 내용 초기화 const resetForm = useCallback(() => { initialContent(); resizeTextarea(); }, [initialContent, resizeTextarea]); // 댓글+답글 등록 const handleSubmit = useCallback(async () => { if (!loginCheck()) { return; } if (!form.content.trim()) { if (boardMeta.list.layout === BoardLayout.QnA) { editorRef.current?.editorInstance?.editing.view.focus(); } else { textareaRef.current?.focus(); } return setError('내용을 입력해주세요.'); } setLoading(true); try { { const trimmedContent = form.content.trim(); const maxLen = boardMeta.comment.maxContentLength || CommentConst.MaxAllowedContentLength; const minLen = boardMeta.comment.minContentLength; if (minLen > 0 && trimmedContent.length < minLen) { if (boardMeta.comment.enableEditor) { editorRef.current?.editorInstance?.editing.view.focus(); } else { textareaRef.current?.focus(); } throw new Error(`내용은 ${minLen}자 이상 작성해주세요.`); } if (trimmedContent.length > maxLen) { if (boardMeta.comment.enableEditor) { editorRef.current?.editorInstance?.editing.view.focus(); } else { textareaRef.current?.focus(); } throw new Error(`내용은 ${maxLen}자 이내로 작성해주세요.`); } } const formData = new FormData(); formData.append('postID', String(form.postID)); formData.append('isSecret', String(form.isSecret)); if (form.parentID) { formData.append('parentID', String(form.parentID)); } // content에서 자동으로 붙은 @회원을 제거한다. let content = form.content; const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (form.mention) { content = content.replace(new RegExp('^\\s*' + escapeRegExp(form.mention) + '\\s*', 'i') , ''); formData.append('mention', form.mention.trim()); } // 1:1 문의일 경우 검사 if (boardMeta.list.layout === BoardLayout.QnA) { if (content) { const doc = new DOMParser().parseFromString(content, 'text/html'); doc.querySelectorAll('img[src]').forEach(img => { img.setAttribute('src', 'data:image/'); }); content = doc.body.innerHTML; } // 이미지 정보 editorRef.current?.getImageStore().forEach((i) => { if (i.image?.size > 0 && i.name) { formData.append('images', i.image, i.name); } }); // 미디어 정보 editorRef.current?.getMediaStore().forEach((m) => { if (m.url) { formData.append('medias', m.url); } }); // 첨부 파일 editorRef.current?.getFileStore().forEach((f) => { if (f?.size > 0 && f.name) { formData.append('files', f.file, f.name); } }); } formData.append('content', content); const res = await fetchApi('/api/forum/comments', { method: 'POST', body: formData }); const newComment: CommentItem = { id: res.data!.id, postID: _post.id, memberID: member!.id, parentID: form.parentID, writer: { id: member!.id, sid: member!.sid ?? '', name: member!.name, thumbnail: member!.thumb, icon: member!.icon, createdAt: '' }, mention: form.mention ? { id: 0, rawHandle: form.mention } : undefined, content: form.content, isReply: !!form.parentID, isSecret: form.isSecret, likes: 0, dislikes: 0, reports: 0, replies: 0, hasLike: false, hasDislike: false, hasReport: false, createdAt: new Date().toISOString(), children: [] }; onSuccess(newComment); } catch (err) { if (err instanceof Error) { setError(err.message); } } finally { setLoading(false); resetForm(); } }, [form, boardMeta, _post.id, member, loginCheck, onSuccess, setError, resetForm]); // Ctrl + Enter 최종 제출 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); handleSubmit(); } }, [handleSubmit]); // 취소 const handleCancel = useCallback(() => { if (onCancel) { onCancel(); } else { resetForm(); } }, [onCancel, resetForm]); const handleEditorChange = useCallback((data: string) => { setForm(prev => ({ ...prev, content: data })); }, []); return ( <> {loading ? (
) : (
{boardMeta.comment.showMemberThumb && (
{member?.name
)}
{boardMeta.comment.enableEditor ? ( ) : (