| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- 'use client';
- import './style.scss';
- import { useRouter } from 'next/navigation';
- import Link from 'next/link';
- import { useState, useEffect, useCallback, useRef, FormEvent } from 'react';
- import Loading from '@/app/component/Loading';
- import { BoardLayout, PostConst } from '@/constants/forum';
- import { fetchBoard, fetchBoardList } from '@/lib/api/forum/board';
- import { fetchPostCreate } from '@/lib/api/forum/post';
- import { throwError } from '@/lib/utils/client';
- import boardResponse from '@/dtos/response/forum/board/boardResponse';
- import BoardListResponse from '@/dtos/response/forum/board/boardListResponse';
- import Editor, { Handle } from '../_component/Editor';
- import HeaderContent from '../_component/HeaderContent';
- import FooterContent from '../_component/FooterContent';
- import PostTagInput from '../_component/PostTagInput';
- export default function View()
- {
- const router = useRouter();
- const editorRef = useRef<Handle>(null);
- const [error, setError] = useState<string|null>(null);
- const [loading, setLoading] = useState<boolean>(false);
- const [isChanged, setIsChanged] = useState<boolean>(false);
- const [board, setBoard] = useState<boardResponse|null>(null);
- const [boardList, setBoardList] = useState<BoardListResponse[]>([]);
- const [boardCode, setBoardCode] = useState<string>('');
- const [boardPrefix, setBoardPrefix] = useState<string>('');
- const [subject, setSubject] = useState<string>('');
- const [content, setContent] = useState<string>('');
- const [isSecret, setIsSecret] = useState<boolean>(false);
- const [isNotice, setIsNotice] = useState<boolean>(false);
- const [isSpeaker, setIsSpeaker] = useState<boolean>(false);
- const [tags, setTags] = useState<string[]>([]);
- const boardCodeRef = useRef<HTMLSelectElement>(null);
- const boardPrefixRef = useRef<HTMLSelectElement>(null);
- const subjectRef = useRef<HTMLInputElement>(null);
- const contentRef = useRef<HTMLTextAreaElement>(null);
- useEffect(() => {
- if (error) {
- alert(error);
- setError(null);
- }
- }, [error]);
- useEffect(() => {
- const hash = window.location.hash;
- if (hash) {
- setBoardCode(hash.substring(1));
- }
- }, []);
- useEffect(() => {
- if (boardCode) {
- setLoading(true);
- // 게시판 상세 정보 호출
- fetchBoard(boardCode).then((res) => {
- if (res.success) {
- setBoard(res.data);
- } else {
- throw new Error('게시판을 조회할 수 없습니다.');
- }
- }).catch((err) => {
- setError(err.message);
- }).finally(() => {
- setLoading(false);
- });
- }
- }, [boardCode]);
- // 게시판 변경 시
- useEffect(() => {
- if (board) {
- resetForm();
- // 게시판 목록 호출
- if (boardList.length <= 0) {
- fetchBoardList(board.boardGroup.code).then((res) => {
- if (res.success) {
- if (res.data && res.data.length >= 0) {
- setBoardList(res.data);
- }
- } else {
- throw new Error('게시판을 조회할 수 없습니다.');
- }
- }).catch((err) => {
- setError(err.message);
- }).finally(() => {
- setLoading(false);
- });
- }
- }
- }, [board]);
- // 게시글 초기화
- const resetForm = () => {
- setError('');
- setIsChanged(false);
- setBoardPrefix('');
- setSubject(board?.boardMeta.write.defaultSubject || '');
- setContent(board?.boardMeta.write.defaultContent || '');
- setIsSecret(false);
- setIsNotice(false);
- setIsSpeaker(false);
- setTags([]);
- // Editor 초기화
- if (editorRef.current?.editorInstance) {
- editorRef.current.editorInstance.setData(content);
- }
- };
- // 게시판 선택 시
- const handleBoardChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
- const code = e.target.value;
- if (isChanged) {
- if (!confirm('작성 중인 내용이 사라질 수 있습니다. 게시판을 변경하시겠습니까?')) {
- return
- }
- }
- setBoardCode(code);
- setIsChanged(false);
- };
- // 제목, 내용, 말머리 변경 시
- const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement>) => {
- const { name, value } = e.target as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement;
- const checked = (e.target as HTMLInputElement).checked;
- switch (name) {
- case 'boardPrefix':
- setBoardPrefix(value);
- break;
- case 'isSecret':
- setIsSecret(checked);
- break;
- case 'isNotice':
- setIsNotice(checked);
- setIsSpeaker(false);
- break;
- case 'isSpeaker':
- setIsSpeaker(checked);
- setIsNotice(false);
- break;
- case 'subject':
- setSubject(value);
- break;
- case 'content':
- setContent(value);
- break;
- }
- setIsChanged(true);
- };
- // CKEditor에서 내용 변경 시
- const handleEditorChange = useCallback((data: string) => {
- setContent(data);
- setIsChanged(true);
- }, []);
- const validate = () => {
- if (!boardCode || !board) {
- boardCodeRef.current!.focus();
- throw new Error('게시판을 선택해주세요.');
- }
- if (board.boardMeta.write.allowPrefix && board.boardMeta.write.requiredPrefix && !boardPrefix) {
- boardPrefixRef.current!.focus();
- throw new Error((board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + '를 선택해주세요.');
- }
- if (!subject) {
- subjectRef.current!.focus();
- throw new Error('제목을 입력해주세요.');
- } else if (subject.length > PostConst.MaxAllowedSubjectLength) {
- subjectRef.current!.focus();
- throw new Error(`제목은 ${PostConst.MaxAllowedSubjectLength}자 이내로 작성해주세요.`);
- }
- if (!content) {
- if (board.boardMeta.write.allowEditor) {
- editorRef.current!.editorInstance?.editing.view.focus();
- } else {
- contentRef.current!.focus();
- }
- throw new Error('내용을 입력해주세요.');
- } else if (!board.boardMeta.write.allowEditor) {
- // 기본 textarea 사용 시 글자 수 검사
- if (content.length > PostConst.MaxAllowedContentLength) {
- contentRef.current!.focus();
- throw new Error(`내용은 ${PostConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
- }
- }
- if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
- throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
- }
- };
- // 게시글 등록 처리
- const handleSubmit = useCallback(async (e: FormEvent) => {
- e.preventDefault();
- try {
- validate();
- setLoading(true);
- if (!board) {
- throw new Error('게시판을 선택해 주세요.');
- }
- const formData = new FormData();
- formData.append('boardID', String(board.id));
- formData.append('boardCode', boardCode);
- formData.append('boardPrefixID', boardPrefix);
- formData.append('isSecret', String(isSecret));
- formData.append('isNotice', String(isNotice));
- formData.append('isSpeaker', String(isSpeaker));
- formData.append('subject', subject);
- if (content) {
- const doc = new DOMParser().parseFromString(content, 'text/html');
- doc.querySelectorAll('img[src]').forEach(img => {
- img.setAttribute('src', 'data:image/');
- });
- formData.append('content', doc.body.innerHTML);
- }
- // 태그
- if (board.boardMeta.write.allowTag) {
- tags.forEach(tag => formData.append('tags', tag));
- }
- // 이미지 정보
- if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowImage) {
- editorRef.current?.getImageStore().forEach(i => {
- if (i.image?.size > 0 && i.name) {
- formData.append('images', i.image, i.name);
- }
- });
- }
- // 미디어 정보
- if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowMedia) {
- editorRef.current!.getMediaStore().forEach((m) => {
- if (m.url) {
- formData.append('medias', m.url);
- }
- });
- }
- // 첨부 파일
- if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowFile) {
- editorRef.current!.getFileStore().forEach(f => {
- if (f?.size > 0 && f.name) {
- formData.append('files', f.file, f.name);
- }
- });
- }
- const res = await fetchPostCreate(formData);
- if (res.success) {
- resetForm();
- router.push(`/post/${res.data!.id}`);
- } else {
- throwError(res);
- }
- } catch (err: any) {
- setError(err.message);
- } finally {
- setLoading(false);
- }
- }, [boardCode, board, boardPrefix, subject, content, isSecret, isNotice, isSpeaker, tags]);
- return (
- <form id='postWrite' onSubmit={handleSubmit}>
- {loading && <Loading />}
- <fieldset>
- <legend><h1>{board?.name} 글쓰기</h1></legend>
- {/* 상단 안내 */}
- {<HeaderContent isEnabled={board?.boardMeta.write.showHeader} content={board?.boardMeta.write.headerContent}/>}
- {/* 게시판 선택, 말머리, 비밀글, 공지, 전체 공지 */}
- <section>
- {/* 게시판 선택 */}
- <article>
- <select name='boardCode' ref={boardCodeRef} value={boardCode} onChange={handleBoardChange} title='게시판 선택'>
- <option value=''>게시판 선택</option>
- {boardList.map((board) => (
- <option key={board.code} value={board.code}>{board.name}</option>
- ))}
- </select>
- </article>
- {/* 말머리 */}
- {board?.boardMeta.write.allowPrefix && (
- <article>
- <select name='boardPrefix' ref={boardPrefixRef} value={boardPrefix} onChange={handleChange} title='말머리 선택'>
- <option value=''>{(board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + ' 선택'}</option>
- {board?.boardPrefix.map((row) => (
- <option key={row.id} value={row.id}>{row.name}</option>
- ))}
- </select>
- </article>
- )}
- <article>
- {/* 비밀글 */}
- {board?.boardMeta.write.allowSecret && (
- <>
- <input type='checkbox' name='isSecret' id='isSecret' checked={isSecret} onChange={handleChange} />
- <label htmlFor='isSecret'>비밀글</label>
- </>
- )}
- {/* 해당 게시판 공지 */}
- <input type='checkbox' name='isNotice' id='isNotice' checked={isNotice} onChange={handleChange} />
- <label htmlFor='isNotice'>공지</label>
- {/* 게시판 전체 공지 */}
- <input type='checkbox' name='isSpeaker' id='isSpeaker' checked={isSpeaker} onChange={handleChange} />
- <label htmlFor='isSpeaker'>전체 공지</label>
- </article>
- </section>
- {/* 제목 */}
- <section>
- <input type='text' name='subject' ref={subjectRef} value={subject} onChange={handleChange} placeholder='글 제목을 입력해주세요.' autoFocus maxLength={PostConst.MaxAllowedSubjectLength} />
- </section>
- {/* 내용 */}
- <section>
- {board?.boardMeta.write.allowEditor ?
- (
- <Editor ref={editorRef} key={boardCode} data={content} onChange={handleEditorChange} boardMeta={board?.boardMeta} />
- ) : (
- <textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.MaxAllowedContentLength}></textarea>
- )}
- </section>
- {/* 태그 */}
- {board?.boardMeta.write?.allowTag && (
- <section id='postTag'>
- <PostTagInput value={tags} onChange={setTags} maxTags={board.boardMeta.write.tagLimit} />
- </section>
- )}
- {/* 하단 안내 */}
- {<FooterContent isEnabled={board?.boardMeta.write.showFooter} content={board?.boardMeta.write.footerContent}/>}
- <br/>
- <section>
- <button type='submit' className='btn btn-submit' disabled={loading}>
- { loading ? '등록 중…' : '확인' }
- </button>
- <Link href={`/board/${boardCode || 'latest'}`} className='btn btn-default'>취소</Link>
- </section>
- </fieldset>
- </form>
- );
- }
|