EditForm.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. 'use client';
  2. import '../style.scss';
  3. import { useState, useRef, useEffect, useCallback } from 'react';
  4. import useErrorAlert from '@/hooks/useErrorAlert';
  5. import Editor, { type Handle as EditorHandle } from '@/app/(main)/(forum)/post/_component/Editor';
  6. import { BoardResponse } from '@/types/response/forum/board';
  7. import { PostResponse } from '@/types/response/forum/post';
  8. import { CommentUpdateRequest } from '@/types/request/forum/comment';
  9. import { CommentUpdateResponse } from '@/types/response/forum/comment';
  10. import { type CommentItem } from '@/types/forum/comment';
  11. import EmojiPicker from '@/app/component/EmojiPicker';
  12. import { CommentConst } from '@/constants/forum';
  13. import { fetchApi } from '@/lib/utils/client';
  14. import useAuth from '@/hooks/useAuth';
  15. import Loading from '@/app/component/Loading';
  16. type Props = {
  17. _board: BoardResponse,
  18. _post: PostResponse,
  19. _comment: CommentItem,
  20. onSuccess: (comment?: CommentItem) => void;
  21. onCancel?: () => void;
  22. };
  23. export default function EditForm({ _board, _post, _comment, onSuccess, onCancel }: Props)
  24. {
  25. const { loginCheck } = useAuth();
  26. const boardMeta = _board.boardMeta;
  27. const { setError } = useErrorAlert();
  28. const [loading, setLoading] = useState<boolean>(false);
  29. const [form, setForm] = useState<CommentUpdateRequest>({
  30. postID: _post.id,
  31. commentID: _comment.id,
  32. mention: _comment.mention?.rawHandle ?? '',
  33. content: _comment.content,
  34. isSecret: _comment.isSecret
  35. });
  36. const textareaRef = useRef<HTMLTextAreaElement>(null);
  37. const editorRef = useRef<EditorHandle>(null);
  38. // 입력 창 높이 조절
  39. const resizeTextarea = useCallback(() => {
  40. const textarea = textareaRef.current;
  41. if (textarea) {
  42. textarea.style.height = 'auto';
  43. textarea.style.height = `${textarea.scrollHeight}px`;
  44. }
  45. }, []);
  46. useEffect(() => {
  47. resizeTextarea();
  48. }, [form.content, resizeTextarea]);
  49. const handleSecretChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  50. setForm(prev => ({ ...prev, isSecret: e.target.checked }));
  51. }, []);
  52. const handleEmoji = useCallback((emoji: string) => {
  53. setForm(prev => ({ ...prev, content: prev.content + emoji }));
  54. }, []);
  55. const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  56. if (e.ctrlKey && e.key === 'Enter') {
  57. e.preventDefault();
  58. handleSubmit();
  59. }
  60. }, []);
  61. // 입력 내용 초기화
  62. const resetForm = useCallback(() => {
  63. setForm({
  64. postID: _post.id,
  65. commentID: _comment.id,
  66. mention: '',
  67. content: '',
  68. isSecret: false
  69. });
  70. resizeTextarea();
  71. }, [_post.id, _comment.id, resizeTextarea]);
  72. // 댓글+답글 수정
  73. const handleSubmit = useCallback(async () => {
  74. if (!loginCheck()) {
  75. return;
  76. }
  77. if (!form.content.trim()) {
  78. if (boardMeta.comment.enableEditor) {
  79. editorRef.current?.editorInstance?.editing.view.focus();
  80. } else {
  81. textareaRef.current?.focus();
  82. }
  83. setError('내용을 입력해주세요.');
  84. return;
  85. }
  86. setLoading(true);
  87. try {
  88. {
  89. const trimmedContent = form.content.trim();
  90. const maxLen = boardMeta.comment.maxContentLength || CommentConst.MaxAllowedContentLength;
  91. const minLen = boardMeta.comment.minContentLength;
  92. if (minLen > 0 && trimmedContent.length < minLen) {
  93. if (boardMeta.comment.enableEditor) {
  94. editorRef.current?.editorInstance?.editing.view.focus();
  95. } else {
  96. textareaRef.current?.focus();
  97. }
  98. throw new Error(`내용은 ${minLen}자 이상 작성해주세요.`);
  99. }
  100. if (trimmedContent.length > maxLen) {
  101. if (boardMeta.comment.enableEditor) {
  102. editorRef.current?.editorInstance?.editing.view.focus();
  103. } else {
  104. textareaRef.current?.focus();
  105. }
  106. throw new Error(`내용은 ${maxLen}자 이내로 작성해주세요.`);
  107. }
  108. }
  109. const formData = new FormData();
  110. formData.append('postID', String(form.postID));
  111. formData.append('commentID', String(form.commentID));
  112. formData.append('isSecret', String(form.isSecret));
  113. let content = form.content;
  114. const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  115. if (form.mention) {
  116. content = content.replace(new RegExp('^\\s*' + escapeRegExp(form.mention) + '\\s*', 'i') , '');
  117. formData.append('mention', form.mention.trim());
  118. }
  119. // QnA 처리
  120. if (boardMeta.comment.enableEditor) {
  121. if (content) {
  122. const doc = new DOMParser().parseFromString(content, 'text/html');
  123. doc.querySelectorAll('img[src]').forEach(img => {
  124. img.setAttribute('src', 'data:image/');
  125. });
  126. content = doc.body.innerHTML;
  127. }
  128. // 이미지 정보
  129. editorRef.current?.getImageStore().forEach((i) => {
  130. if (i.image?.size > 0 && i.name) {
  131. formData.append('images', i.image, i.name);
  132. }
  133. });
  134. // 미디어 정보
  135. editorRef.current?.getMediaStore().forEach((m) => {
  136. if (m.url) {
  137. formData.append('medias', m.url);
  138. }
  139. });
  140. // 첨부 파일
  141. editorRef.current?.getFileStore().forEach((f) => {
  142. if (f?.size > 0 && f.name) {
  143. formData.append('files', f.file, f.name);
  144. }
  145. });
  146. }
  147. formData.append('content', content);
  148. const res = await fetchApi<CommentUpdateResponse>('/api/forum/comments/' + _comment.id, {
  149. method: 'PUT',
  150. body: formData
  151. });
  152. const updatedComment: CommentItem = {
  153. ..._comment,
  154. content: content,
  155. mention: form.mention ? { id: _comment.mention?.id ?? 0, rawHandle: form.mention } : undefined,
  156. isSecret: form.isSecret
  157. };
  158. onSuccess(updatedComment);
  159. } catch (err) {
  160. if (err instanceof Error) {
  161. setError(err.message);
  162. }
  163. } finally {
  164. setLoading(false);
  165. resetForm();
  166. }
  167. }, [form, boardMeta, _comment, loginCheck, onSuccess, setError, resetForm]);
  168. const handleCancel = useCallback(() => {
  169. if (onCancel) {
  170. onCancel();
  171. } else {
  172. resetForm();
  173. }
  174. }, [onCancel, resetForm]);
  175. const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
  176. setForm(prev => ({ ...prev, content: e.target.value }));
  177. }, []);
  178. const handleEditorChange = useCallback((data: string) => {
  179. setForm(prev => ({ ...prev, content: data }));
  180. }, []);
  181. return (
  182. <>
  183. {loading ? (
  184. <article>
  185. <Loading type={2} />
  186. </article>
  187. ) : (
  188. <article className='edit-form'>
  189. <div>
  190. {boardMeta.comment.enableEditor ? (
  191. <Editor
  192. ref={editorRef}
  193. editorKey={`comment-edit-${_comment.id}`}
  194. data={form.content}
  195. onChange={handleEditorChange}
  196. boardMeta={boardMeta}
  197. />
  198. ) : (
  199. <textarea
  200. name='_comment'
  201. rows={1}
  202. value={form.content}
  203. title='댓글 입력 창'
  204. placeholder={boardMeta.comment.contentPlaceholder ?? ''}
  205. ref={textareaRef}
  206. onChange={handleContentChange}
  207. onKeyDown={handleKeyDown}
  208. disabled={loading}
  209. />
  210. )}
  211. </div>
  212. <div>
  213. <EmojiPicker onEmojiSelect={handleEmoji} />
  214. {boardMeta.comment.allowSecret && (
  215. <label className='ps-2'>
  216. <input type="checkbox" checked={form.isSecret} onChange={handleSecretChange} />
  217. <span className='ps-1'>비밀글</span>
  218. </label>
  219. )}
  220. </div>
  221. <div>
  222. <button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
  223. <button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
  224. </div>
  225. </article>
  226. )}
  227. </>
  228. )
  229. };