view.tsx 14 KB

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