WriteForm.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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, throwError } 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. throwError(res);
  181. const newComment: CommentItem = {
  182. id: res.data!.id,
  183. postID: _post.id,
  184. memberID: member!.id,
  185. parentID: form.parentID,
  186. writer: {
  187. id: member!.id,
  188. sid: member!.sid ?? '',
  189. name: member!.name,
  190. thumbnail: member!.thumb,
  191. icon: member!.icon,
  192. createdAt: ''
  193. },
  194. mention: form.mention ? { id: 0, rawHandle: form.mention } : undefined,
  195. content: form.content,
  196. isReply: !!form.parentID,
  197. isSecret: form.isSecret,
  198. likes: 0, dislikes: 0, reports: 0, replies: 0,
  199. hasLike: false, hasDislike: false, hasReport: false,
  200. createdAt: new Date().toISOString(),
  201. children: []
  202. };
  203. onSuccess(newComment);
  204. } catch (err) {
  205. if (err instanceof Error) {
  206. setError(err.message);
  207. }
  208. } finally {
  209. setLoading(false);
  210. resetForm();
  211. }
  212. }, [form, boardMeta, _post.id, member, loginCheck, onSuccess, setError, resetForm]);
  213. // Ctrl + Enter 최종 제출
  214. const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  215. if (e.ctrlKey && e.key === 'Enter') {
  216. e.preventDefault();
  217. handleSubmit();
  218. }
  219. }, [handleSubmit]);
  220. // 취소
  221. const handleCancel = useCallback(() => {
  222. if (onCancel) {
  223. onCancel();
  224. } else {
  225. resetForm();
  226. }
  227. }, [onCancel, resetForm]);
  228. const handleEditorChange = useCallback((data: string) => {
  229. setForm(prev => ({ ...prev, content: data }));
  230. }, []);
  231. return (
  232. <>
  233. {loading ? (
  234. <article>
  235. <Loading type={2} />
  236. </article>
  237. ) : (
  238. <article className='write-form'>
  239. {boardMeta.comment.showMemberThumb && (
  240. <div>
  241. <Image src={member?.thumb ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
  242. </div>
  243. )}
  244. <div>
  245. <div className='relative'>
  246. <MentionSuggestion postID={_post.id} textareaRef={textareaRef} onSelect={handleMentionSelect} />
  247. {boardMeta.comment.enableEditor ? (
  248. <Editor
  249. ref={editorRef}
  250. editorKey='_comment'
  251. data={form.content}
  252. onChange={handleEditorChange}
  253. boardMeta={boardMeta}
  254. />
  255. ) : (
  256. <textarea
  257. name='_comment'
  258. rows={1}
  259. value={form.content}
  260. title='댓글 입력 창'
  261. placeholder={boardMeta.comment.contentPlaceholder ?? ''}
  262. ref={textareaRef}
  263. onChange={handleContentChange}
  264. onKeyDown={handleKeyDown}
  265. disabled={loading}
  266. />
  267. )}
  268. </div>
  269. <div>
  270. <EmojiPicker onEmojiSelect={handleEmoji} />
  271. {boardMeta.comment.allowSecret && (
  272. <label className='ps-2'>
  273. <input type="checkbox" checked={form.isSecret} onChange={handleSecretChange} />
  274. <span className='ps-1'>비밀글</span>
  275. </label>
  276. )}
  277. </div>
  278. <div>
  279. <button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
  280. <button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
  281. </div>
  282. </div>
  283. </article>
  284. )}
  285. </>
  286. )
  287. };