WriteForm.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. 'use client';
  2. import '../style.scss';
  3. import Image from 'next/image';
  4. import { useState, useRef, useEffect, useCallback } from 'react';
  5. import Editor, { type Handle as EditorHandle } from '@/app/(main)/(forum)/post/_component/Editor';
  6. import EmojiPicker from '@/app/component/EmojiPicker';
  7. import Loading from '@/app/component/Loading';
  8. import { useMemberContext } from '@/contexts/memberProvider';
  9. import useErrorAlert from '@/hooks/useErrorAlert';
  10. import { BoardResponse } from '@/types/response/forum/board';
  11. import { PostResponse } from '@/types/response/forum/post';
  12. import { CommentCreateRequest } from '@/types/request/forum/comment';
  13. import { CommentCreateResponse } from '@/types/response/forum/comment';
  14. import { CommentItem } from '@/types/forum/comment';
  15. import { BoardLayout, CommentConst } from '@/constants/forum';
  16. import { fetchApi } from '@/lib/utils/client';
  17. import useAuth from '@/hooks/useAuth';
  18. import MentionSuggestion from './MentionSuggestion';
  19. type Props = {
  20. _board: BoardResponse,
  21. _post: PostResponse,
  22. _comment?: CommentItem; // null이면 새 댓글, 값이 있으면 답글
  23. onSuccess: (comment?: CommentItem) => void;
  24. onCancel?: () => void;
  25. };
  26. export default function WriteForm({ _board, _post, _comment, onSuccess, onCancel }: Props)
  27. {
  28. const { member } = useMemberContext();
  29. const { loginCheck } = useAuth();
  30. const boardMeta = _board.boardMeta;
  31. const { setError } = useErrorAlert();
  32. const [loading, setLoading] = useState<boolean>(false);
  33. const [form, setForm] = useState<CommentCreateRequest>({
  34. postID: _post.id,
  35. parentID: _comment ? (_comment.parentID ?? _comment.id) : undefined,
  36. mention: '',
  37. content: '',
  38. isSecret: false
  39. });
  40. const textareaRef = useRef<HTMLTextAreaElement>(null);
  41. const editorRef = useRef<EditorHandle>(null);
  42. const initialContent = useCallback(() => {
  43. if (_comment) {
  44. // YouTube 스타일: 항상 최상위 댓글 아래에 붙임
  45. const resolvedParentID = _comment.parentID ?? _comment.id;
  46. const stored = localStorage.getItem('member');
  47. const myID = stored ? JSON.parse(stored).id : null;
  48. if (_comment.parentID && myID !== _comment.writer.id) {
  49. // 대댓글 + 다른 사용자 > 답글 대상의 작성자를 멘션
  50. const writerHandle = _comment.writer.name || _comment.writer.sid;
  51. const mentionText = `@${writerHandle} `;
  52. setForm(prev => ({ ...prev, parentID: resolvedParentID, mention: `@${writerHandle}`, content: mentionText }));
  53. setTimeout(() => textareaRef.current?.focus(), 0);
  54. } else {
  55. // 최상위 댓글 답글 또는 본인 댓글 답글
  56. setForm(prev => ({ ...prev, parentID: resolvedParentID, mention: '', content: '' }));
  57. }
  58. } else {
  59. // 새 댓글
  60. setForm(prev => ({ ...prev, parentID: undefined, mention: '', content: '' }));
  61. }
  62. }, [_comment]);
  63. useEffect(() => {
  64. initialContent();
  65. }, [initialContent]);
  66. // 입력 창 높이 조절
  67. const resizeTextarea = useCallback(() => {
  68. const textarea = textareaRef.current;
  69. if (textarea) {
  70. textarea.style.height = 'auto'; // 초기화
  71. textarea.style.height = `${textarea.scrollHeight}px`; // 높이 조정
  72. }
  73. }, []);
  74. useEffect(() => {
  75. resizeTextarea();
  76. }, [form.content, resizeTextarea]);
  77. // 멘션 자동완성 선택
  78. const handleMentionSelect = useCallback((newContent: string) => {
  79. setForm(prev => ({ ...prev, content: newContent }));
  80. }, []);
  81. // 비밀글 체크
  82. const handleSecretChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  83. setForm(prev => ({ ...prev, isSecret: e.target.checked }));
  84. }, []);
  85. // 이모지 선택
  86. const handleEmoji = useCallback((emoji: string) => {
  87. setForm(prev => ({ ...prev, content: prev.content + emoji }));
  88. }, []);
  89. // 입력 내용 저장
  90. const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
  91. setForm(prev => ({ ...prev, content: e.target.value }));
  92. }, []);
  93. // 입력 내용 초기화
  94. const resetForm = useCallback(() => {
  95. initialContent();
  96. resizeTextarea();
  97. }, [initialContent, resizeTextarea]);
  98. // 댓글+답글 등록
  99. const handleSubmit = useCallback(async () => {
  100. if (!loginCheck()) {
  101. return;
  102. }
  103. if (!form.content.trim()) {
  104. if (boardMeta.list.layout === BoardLayout.QnA) {
  105. editorRef.current?.editorInstance?.editing.view.focus();
  106. } else {
  107. textareaRef.current?.focus();
  108. }
  109. return setError('내용을 입력해주세요.');
  110. }
  111. setLoading(true);
  112. try {
  113. {
  114. const trimmedContent = form.content.trim();
  115. const maxLen = boardMeta.comment.maxContentLength || CommentConst.MaxAllowedContentLength;
  116. const minLen = boardMeta.comment.minContentLength;
  117. if (minLen > 0 && trimmedContent.length < minLen) {
  118. if (boardMeta.comment.enableEditor) {
  119. editorRef.current?.editorInstance?.editing.view.focus();
  120. } else {
  121. textareaRef.current?.focus();
  122. }
  123. throw new Error(`내용은 ${minLen}자 이상 작성해주세요.`);
  124. }
  125. if (trimmedContent.length > maxLen) {
  126. if (boardMeta.comment.enableEditor) {
  127. editorRef.current?.editorInstance?.editing.view.focus();
  128. } else {
  129. textareaRef.current?.focus();
  130. }
  131. throw new Error(`내용은 ${maxLen}자 이내로 작성해주세요.`);
  132. }
  133. }
  134. const formData = new FormData();
  135. formData.append('postID', String(form.postID));
  136. formData.append('isSecret', String(form.isSecret));
  137. if (form.parentID) {
  138. formData.append('parentID', String(form.parentID));
  139. }
  140. // content에서 자동으로 붙은 @회원을 제거한다.
  141. let content = form.content;
  142. const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  143. if (form.mention) {
  144. content = content.replace(new RegExp('^\\s*' + escapeRegExp(form.mention) + '\\s*', 'i') , '');
  145. formData.append('mention', form.mention.trim());
  146. }
  147. // 1:1 문의일 경우 검사
  148. if (boardMeta.list.layout === BoardLayout.QnA) {
  149. if (content) {
  150. const doc = new DOMParser().parseFromString(content, 'text/html');
  151. doc.querySelectorAll('img[src]').forEach(img => {
  152. img.setAttribute('src', 'data:image/');
  153. });
  154. content = doc.body.innerHTML;
  155. }
  156. // 이미지 정보
  157. editorRef.current?.getImageStore().forEach((i) => {
  158. if (i.image?.size > 0 && i.name) {
  159. formData.append('images', i.image, i.name);
  160. }
  161. });
  162. // 미디어 정보
  163. editorRef.current?.getMediaStore().forEach((m) => {
  164. if (m.url) {
  165. formData.append('medias', m.url);
  166. }
  167. });
  168. // 첨부 파일
  169. editorRef.current?.getFileStore().forEach((f) => {
  170. if (f?.size > 0 && f.name) {
  171. formData.append('files', f.file, f.name);
  172. }
  173. });
  174. }
  175. formData.append('content', content);
  176. const res = await fetchApi<CommentCreateResponse>('/api/forum/comments', {
  177. method: 'POST',
  178. body: formData
  179. });
  180. const newComment: CommentItem = {
  181. id: res.data!.id,
  182. postID: _post.id,
  183. memberID: member!.id,
  184. parentID: form.parentID,
  185. writer: {
  186. id: member!.id,
  187. sid: member!.sid ?? '',
  188. name: member!.name,
  189. thumbnail: member!.thumb,
  190. icon: member!.icon,
  191. createdAt: ''
  192. },
  193. mention: form.mention ? { id: 0, rawHandle: form.mention } : undefined,
  194. content: form.content,
  195. isReply: !!form.parentID,
  196. isSecret: form.isSecret,
  197. likes: 0, dislikes: 0, reports: 0, replies: 0,
  198. hasLike: false, hasDislike: false, hasReport: false,
  199. createdAt: new Date().toISOString(),
  200. children: []
  201. };
  202. onSuccess(newComment);
  203. } catch (err) {
  204. if (err instanceof Error) {
  205. setError(err.message);
  206. }
  207. } finally {
  208. setLoading(false);
  209. resetForm();
  210. }
  211. }, [form, boardMeta, _post.id, member, loginCheck, onSuccess, setError, resetForm]);
  212. // Ctrl + Enter 최종 제출
  213. const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  214. if (e.ctrlKey && e.key === 'Enter') {
  215. e.preventDefault();
  216. handleSubmit();
  217. }
  218. }, [handleSubmit]);
  219. // 취소
  220. const handleCancel = useCallback(() => {
  221. if (onCancel) {
  222. onCancel();
  223. } else {
  224. resetForm();
  225. }
  226. }, [onCancel, resetForm]);
  227. const handleEditorChange = useCallback((data: string) => {
  228. setForm(prev => ({ ...prev, content: data }));
  229. }, []);
  230. return (
  231. <>
  232. {loading ? (
  233. <article>
  234. <Loading type={2} />
  235. </article>
  236. ) : (
  237. <article className='write-form'>
  238. {boardMeta.comment.showMemberThumb && (
  239. <div>
  240. <Image src={member?.thumb ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
  241. </div>
  242. )}
  243. <div>
  244. <div className='relative'>
  245. <MentionSuggestion postID={_post.id} textareaRef={textareaRef} onSelect={handleMentionSelect} />
  246. {boardMeta.comment.enableEditor ? (
  247. <Editor
  248. ref={editorRef}
  249. editorKey='_comment'
  250. data={form.content}
  251. onChange={handleEditorChange}
  252. boardMeta={boardMeta}
  253. />
  254. ) : (
  255. <textarea
  256. name='_comment'
  257. rows={1}
  258. value={form.content}
  259. title='댓글 입력 창'
  260. placeholder={boardMeta.comment.contentPlaceholder ?? ''}
  261. ref={textareaRef}
  262. onChange={handleContentChange}
  263. onKeyDown={handleKeyDown}
  264. disabled={loading}
  265. />
  266. )}
  267. </div>
  268. <div>
  269. <EmojiPicker onEmojiSelect={handleEmoji} />
  270. {boardMeta.comment.allowSecret && (
  271. <label className='ps-2'>
  272. <input type="checkbox" checked={form.isSecret} onChange={handleSecretChange} />
  273. <span className='ps-1'>비밀글</span>
  274. </label>
  275. )}
  276. </div>
  277. <div>
  278. <button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
  279. <button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
  280. </div>
  281. </div>
  282. </article>
  283. )}
  284. </>
  285. )
  286. };