view.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. 'use client';
  2. import './style.scss';
  3. import { useRouter } from 'next/navigation';
  4. import Link from 'next/link';
  5. import { useState, useEffect, useCallback, useRef, FormEvent } from 'react';
  6. import Loading from '@/app/component/Loading';
  7. import { BoardLayout, PostConst } from '@/constants/forum';
  8. import { fetchBoard, fetchBoardList } from '@/lib/api/forum/board';
  9. import { fetchPostCreate } from '@/lib/api/forum/post';
  10. import { throwError } from '@/lib/utils/client';
  11. import boardResponse from '@/dtos/response/forum/board/boardResponse';
  12. import BoardListResponse from '@/dtos/response/forum/board/boardListResponse';
  13. import Editor, { Handle } from '../_component/Editor';
  14. import HeaderContent from '../_component/HeaderContent';
  15. import FooterContent from '../_component/FooterContent';
  16. import PostTagInput from '../_component/PostTagInput';
  17. export default function View()
  18. {
  19. const router = useRouter();
  20. const editorRef = useRef<Handle>(null);
  21. const [error, setError] = useState<string|null>(null);
  22. const [loading, setLoading] = useState<boolean>(false);
  23. const [isChanged, setIsChanged] = useState<boolean>(false);
  24. const [board, setBoard] = useState<boardResponse|null>(null);
  25. const [boardList, setBoardList] = useState<BoardListResponse[]>([]);
  26. const [boardCode, setBoardCode] = useState<string>('');
  27. const [boardPrefix, setBoardPrefix] = useState<string>('');
  28. const [subject, setSubject] = useState<string>('');
  29. const [content, setContent] = useState<string>('');
  30. const [isSecret, setIsSecret] = useState<boolean>(false);
  31. const [isNotice, setIsNotice] = useState<boolean>(false);
  32. const [isSpeaker, setIsSpeaker] = useState<boolean>(false);
  33. const [tags, setTags] = useState<string[]>([]);
  34. const boardCodeRef = useRef<HTMLSelectElement>(null);
  35. const boardPrefixRef = useRef<HTMLSelectElement>(null);
  36. const subjectRef = useRef<HTMLInputElement>(null);
  37. const contentRef = useRef<HTMLTextAreaElement>(null);
  38. useEffect(() => {
  39. if (error) {
  40. alert(error);
  41. setError(null);
  42. }
  43. }, [error]);
  44. useEffect(() => {
  45. const hash = window.location.hash;
  46. if (hash) {
  47. setBoardCode(hash.substring(1));
  48. }
  49. }, []);
  50. useEffect(() => {
  51. if (boardCode) {
  52. setLoading(true);
  53. // 게시판 상세 정보 호출
  54. fetchBoard(boardCode).then((res) => {
  55. if (res.success) {
  56. setBoard(res.data);
  57. } else {
  58. throw new Error('게시판을 조회할 수 없습니다.');
  59. }
  60. }).catch((err) => {
  61. setError(err.message);
  62. }).finally(() => {
  63. setLoading(false);
  64. });
  65. }
  66. }, [boardCode]);
  67. // 게시판 변경 시
  68. useEffect(() => {
  69. if (board) {
  70. resetForm();
  71. // 게시판 목록 호출
  72. if (boardList.length <= 0) {
  73. fetchBoardList(board.boardGroup.code).then((res) => {
  74. if (res.success) {
  75. if (res.data && res.data.length >= 0) {
  76. setBoardList(res.data);
  77. }
  78. } else {
  79. throw new Error('게시판을 조회할 수 없습니다.');
  80. }
  81. }).catch((err) => {
  82. setError(err.message);
  83. }).finally(() => {
  84. setLoading(false);
  85. });
  86. }
  87. }
  88. }, [board]);
  89. // 게시글 초기화
  90. const resetForm = () => {
  91. setError('');
  92. setIsChanged(false);
  93. setBoardPrefix('');
  94. setSubject(board?.boardMeta.write.defaultSubject || '');
  95. setContent(board?.boardMeta.write.defaultContent || '');
  96. setIsSecret(false);
  97. setIsNotice(false);
  98. setIsSpeaker(false);
  99. setTags([]);
  100. // Editor 초기화
  101. if (editorRef.current?.editorInstance) {
  102. editorRef.current.editorInstance.setData(content);
  103. }
  104. };
  105. // 게시판 선택 시
  106. const handleBoardChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
  107. const code = e.target.value;
  108. if (isChanged) {
  109. if (!confirm('작성 중인 내용이 사라질 수 있습니다. 게시판을 변경하시겠습니까?')) {
  110. return
  111. }
  112. }
  113. setBoardCode(code);
  114. setIsChanged(false);
  115. };
  116. // 제목, 내용, 말머리 변경 시
  117. const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement>) => {
  118. const { name, value } = e.target as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement;
  119. const checked = (e.target as HTMLInputElement).checked;
  120. switch (name) {
  121. case 'boardPrefix':
  122. setBoardPrefix(value);
  123. break;
  124. case 'isSecret':
  125. setIsSecret(checked);
  126. break;
  127. case 'isNotice':
  128. setIsNotice(checked);
  129. setIsSpeaker(false);
  130. break;
  131. case 'isSpeaker':
  132. setIsSpeaker(checked);
  133. setIsNotice(false);
  134. break;
  135. case 'subject':
  136. setSubject(value);
  137. break;
  138. case 'content':
  139. setContent(value);
  140. break;
  141. }
  142. setIsChanged(true);
  143. };
  144. // CKEditor에서 내용 변경 시
  145. const handleEditorChange = useCallback((data: string) => {
  146. setContent(data);
  147. setIsChanged(true);
  148. }, []);
  149. const validate = () => {
  150. if (!boardCode || !board) {
  151. boardCodeRef.current!.focus();
  152. throw new Error('게시판을 선택해주세요.');
  153. }
  154. if (board.boardMeta.write.allowPrefix && board.boardMeta.write.requiredPrefix && !boardPrefix) {
  155. boardPrefixRef.current!.focus();
  156. throw new Error((board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + '를 선택해주세요.');
  157. }
  158. if (!subject) {
  159. subjectRef.current!.focus();
  160. throw new Error('제목을 입력해주세요.');
  161. } else if (subject.length > PostConst.MaxAllowedSubjectLength) {
  162. subjectRef.current!.focus();
  163. throw new Error(`제목은 ${PostConst.MaxAllowedSubjectLength}자 이내로 작성해주세요.`);
  164. }
  165. if (!content) {
  166. if (board.boardMeta.write.allowEditor) {
  167. editorRef.current!.editorInstance?.editing.view.focus();
  168. } else {
  169. contentRef.current!.focus();
  170. }
  171. throw new Error('내용을 입력해주세요.');
  172. } else if (!board.boardMeta.write.allowEditor) {
  173. // 기본 textarea 사용 시 글자 수 검사
  174. if (content.length > PostConst.MaxAllowedContentLength) {
  175. contentRef.current!.focus();
  176. throw new Error(`내용은 ${PostConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
  177. }
  178. }
  179. if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
  180. throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
  181. }
  182. };
  183. // 게시글 등록 처리
  184. const handleSubmit = useCallback(async (e: FormEvent) => {
  185. e.preventDefault();
  186. try {
  187. validate();
  188. setLoading(true);
  189. if (!board) {
  190. throw new Error('게시판을 선택해 주세요.');
  191. }
  192. const formData = new FormData();
  193. formData.append('boardID', String(board.id));
  194. formData.append('boardCode', boardCode);
  195. formData.append('boardPrefixID', boardPrefix);
  196. formData.append('isSecret', String(isSecret));
  197. formData.append('isNotice', String(isNotice));
  198. formData.append('isSpeaker', String(isSpeaker));
  199. formData.append('subject', subject);
  200. if (content) {
  201. const doc = new DOMParser().parseFromString(content, 'text/html');
  202. doc.querySelectorAll('img[src]').forEach(img => {
  203. img.setAttribute('src', 'data:image/');
  204. });
  205. formData.append('content', doc.body.innerHTML);
  206. }
  207. // 태그
  208. if (board.boardMeta.write.allowTag) {
  209. tags.forEach(tag => formData.append('tags', tag));
  210. }
  211. // 이미지 정보
  212. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowImage) {
  213. editorRef.current?.getImageStore().forEach(i => {
  214. if (i.image?.size > 0 && i.name) {
  215. formData.append('images', i.image, i.name);
  216. }
  217. });
  218. }
  219. // 미디어 정보
  220. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowMedia) {
  221. editorRef.current!.getMediaStore().forEach((m) => {
  222. if (m.url) {
  223. formData.append('medias', m.url);
  224. }
  225. });
  226. }
  227. // 첨부 파일
  228. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowFile) {
  229. editorRef.current!.getFileStore().forEach(f => {
  230. if (f?.size > 0 && f.name) {
  231. formData.append('files', f.file, f.name);
  232. }
  233. });
  234. }
  235. const res = await fetchPostCreate(formData);
  236. if (res.success) {
  237. resetForm();
  238. router.push(`/post/${res.data!.id}`);
  239. } else {
  240. throwError(res);
  241. }
  242. } catch (err: any) {
  243. setError(err.message);
  244. } finally {
  245. setLoading(false);
  246. }
  247. }, [boardCode, board, boardPrefix, subject, content, isSecret, isNotice, isSpeaker, tags]);
  248. return (
  249. <form id='postWrite' onSubmit={handleSubmit}>
  250. {loading && <Loading />}
  251. <fieldset>
  252. <legend><h1>{board?.name} 글쓰기</h1></legend>
  253. {/* 상단 안내 */}
  254. {<HeaderContent isEnabled={board?.boardMeta.write.showHeader} content={board?.boardMeta.write.headerContent}/>}
  255. {/* 게시판 선택, 말머리, 비밀글, 공지, 전체 공지 */}
  256. <section>
  257. {/* 게시판 선택 */}
  258. <article>
  259. <select name='boardCode' ref={boardCodeRef} value={boardCode} onChange={handleBoardChange} title='게시판 선택'>
  260. <option value=''>게시판 선택</option>
  261. {boardList.map((board) => (
  262. <option key={board.code} value={board.code}>{board.name}</option>
  263. ))}
  264. </select>
  265. </article>
  266. {/* 말머리 */}
  267. {board?.boardMeta.write.allowPrefix && (
  268. <article>
  269. <select name='boardPrefix' ref={boardPrefixRef} value={boardPrefix} onChange={handleChange} title='말머리 선택'>
  270. <option value=''>{(board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + ' 선택'}</option>
  271. {board?.boardPrefix.map((row) => (
  272. <option key={row.id} value={row.id}>{row.name}</option>
  273. ))}
  274. </select>
  275. </article>
  276. )}
  277. <article>
  278. {/* 비밀글 */}
  279. {board?.boardMeta.write.allowSecret && (
  280. <>
  281. <input type='checkbox' name='isSecret' id='isSecret' checked={isSecret} onChange={handleChange} />
  282. <label htmlFor='isSecret'>비밀글</label>
  283. </>
  284. )}
  285. {/* 해당 게시판 공지 */}
  286. <input type='checkbox' name='isNotice' id='isNotice' checked={isNotice} onChange={handleChange} />
  287. <label htmlFor='isNotice'>공지</label>
  288. {/* 게시판 전체 공지 */}
  289. <input type='checkbox' name='isSpeaker' id='isSpeaker' checked={isSpeaker} onChange={handleChange} />
  290. <label htmlFor='isSpeaker'>전체 공지</label>
  291. </article>
  292. </section>
  293. {/* 제목 */}
  294. <section>
  295. <input type='text' name='subject' ref={subjectRef} value={subject} onChange={handleChange} placeholder='글 제목을 입력해주세요.' autoFocus maxLength={PostConst.MaxAllowedSubjectLength} />
  296. </section>
  297. {/* 내용 */}
  298. <section>
  299. {board?.boardMeta.write.allowEditor ?
  300. (
  301. <Editor ref={editorRef} key={boardCode} data={content} onChange={handleEditorChange} boardMeta={board?.boardMeta} />
  302. ) : (
  303. <textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.MaxAllowedContentLength}></textarea>
  304. )}
  305. </section>
  306. {/* 태그 */}
  307. {board?.boardMeta.write?.allowTag && (
  308. <section id='postTag'>
  309. <PostTagInput value={tags} onChange={setTags} maxTags={board.boardMeta.write.tagLimit} />
  310. </section>
  311. )}
  312. {/* 하단 안내 */}
  313. {<FooterContent isEnabled={board?.boardMeta.write.showFooter} content={board?.boardMeta.write.footerContent}/>}
  314. <br/>
  315. <section>
  316. <button type='submit' className='btn btn-submit' disabled={loading}>
  317. { loading ? '등록 중…' : '확인' }
  318. </button>
  319. <Link href={`/board/${boardCode || 'latest'}`} className='btn btn-default'>취소</Link>
  320. </section>
  321. </fieldset>
  322. </form>
  323. );
  324. }