WriteForm.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. 'use client';
  2. import '../style.scss';
  3. import Image from 'next/image';
  4. import { useState, useRef, useEffect } from 'react';
  5. import { useMemberContext } from '@/contexts/memberProvider';
  6. import { fetchCommentCreate } from '@/lib/api/forum/comment';
  7. import BoardResponse from '@/dtos/response/forum/board/boardResponse';
  8. import PostResponse from '@/dtos/response/forum/post/postResponse';
  9. import CommentCreateRequest from '@/dtos/request/forum/comment/commentCreateRequest';
  10. import EmojiPicker from '@/app/component/EmojiPicker';
  11. import Loading from '@/app/component/Loading';
  12. import { BoardLayout, CommentConst } from '@/constants/forum';
  13. import { throwError } from '@/lib/utils/server';
  14. import { CommentItem } from '@/types/forum/comment';
  15. type Props = {
  16. board: BoardResponse,
  17. post: PostResponse,
  18. comment?: CommentItem; // null이면 새 댓글, 값이 있으면 답글
  19. onSuccess: () => void;
  20. onCancel?: () => void;
  21. };
  22. export default function WriteForm({ board, post, comment, onSuccess, onCancel }: Props)
  23. {
  24. const { member } = useMemberContext();
  25. const boardMeta = board.boardMeta;
  26. const [error, setError] = useState<string|null>(null);
  27. const [loading, setLoading] = useState<boolean>(false);
  28. const [form, setForm] = useState<CommentCreateRequest>({
  29. postID: post.id,
  30. parentID: comment?.parentID ?? undefined,
  31. mention: '',
  32. content: '',
  33. isSecret: false
  34. });
  35. const textareaRef = useRef<HTMLTextAreaElement>(null);
  36. const contentRef = useRef<HTMLTextAreaElement>(null);
  37. const editorRef = useRef<any>(null);
  38. const initialContent = () => {
  39. if (comment && comment.parentID && member?.id != comment.writer.id) {
  40. const rawHandle = comment.mention?.rawHandle ?? `@${comment.writer.name ?? comment.writer.sid}`;
  41. const mentionText = rawHandle ? (rawHandle.endsWith(' ') ? rawHandle : `${rawHandle} `) : '';
  42. setForm(prev => ({ ...prev, parentID: comment.id ?? undefined, mention: mentionText, content: mentionText + ' ' }));
  43. setTimeout(() => textareaRef.current?.focus(), 0);
  44. } else {
  45. // 새 글일 때는 mention 초기화
  46. setForm(prev => ({ ...prev, parentID: undefined, mention: '' }));
  47. }
  48. };
  49. useEffect(() => {
  50. initialContent();
  51. }, [comment]);
  52. // 입력 창 높이 조절
  53. const resizeTextarea = () => {
  54. const textarea = textareaRef.current;
  55. if (textarea) {
  56. textarea.style.height = 'auto'; // 초기화
  57. textarea.style.height = `${textarea.scrollHeight}px`; // 높이 조정
  58. }
  59. };
  60. useEffect(() => {
  61. if (error) {
  62. alert(error);
  63. setError(null);
  64. }
  65. }, [error]);
  66. useEffect(() => {
  67. resizeTextarea();
  68. }, [form.content]);
  69. // 입력 내용 저장
  70. const handleChange = (key: keyof CommentCreateRequest, value: any) => {
  71. setForm(prev => ({ ...prev, [key]: value }));
  72. };
  73. // 이모지 선택
  74. const handleEmoji = (emoji: string) => {
  75. handleChange("content", (form.content + emoji));
  76. };
  77. // Ctrl + Enter 최종 제출
  78. const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  79. if (e.ctrlKey && e.key === 'Enter') {
  80. e.preventDefault();
  81. handleSubmit();
  82. }
  83. };
  84. // 댓글+답글 등록
  85. const handleSubmit = async () => {
  86. if (!form.content.trim()) {
  87. return;
  88. }
  89. try {
  90. if (!form.content) {
  91. if (boardMeta.list.layout === BoardLayout.QnA) {
  92. editorRef.current!.editorInstance?.editing.view.focus();
  93. } else {
  94. contentRef.current!.focus();
  95. }
  96. throw new Error('내용을 입력해주세요.');
  97. } else if (!boardMeta.comment.enableEditor && !contentRef.current) {
  98. // 기본 textarea 사용 시 글자 수 검사
  99. if (form.content.length > CommentConst.MaxAllowedContentLength) {
  100. contentRef.current!.focus();
  101. throw new Error(`내용은 ${CommentConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
  102. }
  103. }
  104. const formData = new FormData();
  105. formData.append('postID', String(form.postID));
  106. formData.append('isSecret', String(form.isSecret));
  107. if (form.parentID) {
  108. formData.append('parentID', String(form.parentID));
  109. }
  110. // content에서 자동으로 붙은 @회원을 제거한다.
  111. const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  112. if (form.mention) {
  113. form.content = form.content.replace(
  114. new RegExp('^\\s*' + escapeRegExp(form.mention) + '\\s*', 'i')
  115. , '');
  116. }
  117. // 이미지 정보
  118. if (boardMeta.list.layout === BoardLayout.QnA) {
  119. if (form.content) {
  120. const doc = new DOMParser().parseFromString(form.content, 'text/html');
  121. doc.querySelectorAll('img[src]').forEach(img => {
  122. img.setAttribute('src', 'data:image/');
  123. });
  124. form.content = doc.body.innerHTML;
  125. }
  126. editorRef.current?.getImageStore().forEach((i: any) => {
  127. if (i.image?.size > 0 && i.name) {
  128. formData.append('images', i.image, i.name);
  129. }
  130. });
  131. // 미디어 정보
  132. editorRef.current!.getMediaStore().forEach((m: any) => {
  133. if (m.url) {
  134. formData.append('medias', m.url);
  135. }
  136. });
  137. // 첨부 파일
  138. editorRef.current!.getFileStore().forEach((f: any) => {
  139. if (f?.size > 0 && f.name) {
  140. formData.append('files', f.file, f.name);
  141. }
  142. });
  143. }
  144. formData.append('content', form.content);
  145. const res = await fetchCommentCreate(formData);
  146. await throwError(res);
  147. onSuccess();
  148. } catch (err: any) {
  149. setError(err.message);
  150. } finally {
  151. setLoading(false);
  152. resetForm();
  153. }
  154. };
  155. // 입력 내용 초기화
  156. const resetForm = () => {
  157. initialContent();
  158. if (contentRef.current) {
  159. contentRef.current.value = '';
  160. }
  161. if (textareaRef.current) {
  162. textareaRef.current.value = '';
  163. }
  164. resizeTextarea();
  165. };
  166. // 취소
  167. const handleCancel = () => {
  168. if (typeof onCancel === 'function') {
  169. onCancel();
  170. } else {
  171. resetForm();
  172. }
  173. };
  174. return (
  175. <>
  176. {loading ? (
  177. <Loading type={2} />
  178. ) : (
  179. <article className='write-form'>
  180. <div>
  181. <Image src={member?.photo ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
  182. </div>
  183. <div>
  184. <textarea
  185. name='comment'
  186. rows={1}
  187. value={form.content}
  188. title='댓글 입력 창'
  189. placeholder=''
  190. ref={textareaRef}
  191. onChange={(e) => handleChange("content", e.target.value)}
  192. onKeyDown={handleKeyDown}
  193. disabled={loading}
  194. />
  195. </div>
  196. <div>
  197. {/* 비밀글, 이모지 */}
  198. <EmojiPicker onEmojiSelect={handleEmoji} />
  199. </div>
  200. <div>
  201. <button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
  202. <button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
  203. </div>
  204. </article>
  205. )}
  206. </>
  207. )
  208. };