| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- 'use client';
- import '../style.scss';
- import Image from 'next/image';
- import { useState, useRef, useEffect, useCallback } from 'react';
- import Editor, { type Handle as EditorHandle } from '@/app/(main)/(forum)/post/_component/Editor';
- import EmojiPicker from '@/app/component/EmojiPicker';
- import Loading from '@/app/component/Loading';
- import { useMemberContext } from '@/contexts/memberProvider';
- import useErrorAlert from '@/hooks/useErrorAlert';
- import { BoardResponse } from '@/types/response/forum/board';
- import { PostResponse } from '@/types/response/forum/post';
- import { CommentCreateRequest } from '@/types/request/forum/comment';
- import { CommentCreateResponse } from '@/types/response/forum/comment';
- import { CommentItem } from '@/types/forum/comment';
- import { BoardLayout, CommentConst } from '@/constants/forum';
- import { fetchApi } from '@/lib/utils/client';
- import useAuth from '@/hooks/useAuth';
- import MentionSuggestion from './MentionSuggestion';
- type Props = {
- _board: BoardResponse,
- _post: PostResponse,
- _comment?: CommentItem; // null이면 새 댓글, 값이 있으면 답글
- onSuccess: (comment?: CommentItem) => void;
- onCancel?: () => void;
- };
- export default function WriteForm({ _board, _post, _comment, onSuccess, onCancel }: Props)
- {
- const { member } = useMemberContext();
- const { loginCheck } = useAuth();
- const boardMeta = _board.boardMeta;
- const { setError } = useErrorAlert();
- const [loading, setLoading] = useState<boolean>(false);
- const [form, setForm] = useState<CommentCreateRequest>({
- postID: _post.id,
- parentID: _comment ? (_comment.parentID ?? _comment.id) : undefined,
- mention: '',
- content: '',
- isSecret: false
- });
- const textareaRef = useRef<HTMLTextAreaElement>(null);
- const editorRef = useRef<EditorHandle>(null);
- const initialContent = useCallback(() => {
- if (_comment) {
- // YouTube 스타일: 항상 최상위 댓글 아래에 붙임
- const resolvedParentID = _comment.parentID ?? _comment.id;
- const stored = localStorage.getItem('member');
- const myID = stored ? JSON.parse(stored).id : null;
- if (_comment.parentID && myID !== _comment.writer.id) {
- // 대댓글 + 다른 사용자 > 답글 대상의 작성자를 멘션
- const writerHandle = _comment.writer.name || _comment.writer.sid;
- const mentionText = `@${writerHandle} `;
- setForm(prev => ({ ...prev, parentID: resolvedParentID, mention: `@${writerHandle}`, content: mentionText }));
- setTimeout(() => textareaRef.current?.focus(), 0);
- } else {
- // 최상위 댓글 답글 또는 본인 댓글 답글
- setForm(prev => ({ ...prev, parentID: resolvedParentID, mention: '', content: '' }));
- }
- } else {
- // 새 댓글
- setForm(prev => ({ ...prev, parentID: undefined, mention: '', content: '' }));
- }
- }, [_comment]);
- useEffect(() => {
- initialContent();
- }, [initialContent]);
- // 입력 창 높이 조절
- const resizeTextarea = useCallback(() => {
- const textarea = textareaRef.current;
- if (textarea) {
- textarea.style.height = 'auto'; // 초기화
- textarea.style.height = `${textarea.scrollHeight}px`; // 높이 조정
- }
- }, []);
- useEffect(() => {
- resizeTextarea();
- }, [form.content, resizeTextarea]);
- // 멘션 자동완성 선택
- const handleMentionSelect = useCallback((newContent: string) => {
- setForm(prev => ({ ...prev, content: newContent }));
- }, []);
- // 비밀글 체크
- const handleSecretChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
- setForm(prev => ({ ...prev, isSecret: e.target.checked }));
- }, []);
- // 이모지 선택
- const handleEmoji = useCallback((emoji: string) => {
- setForm(prev => ({ ...prev, content: prev.content + emoji }));
- }, []);
- // 입력 내용 저장
- const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
- setForm(prev => ({ ...prev, content: e.target.value }));
- }, []);
- // 입력 내용 초기화
- const resetForm = useCallback(() => {
- initialContent();
- resizeTextarea();
- }, [initialContent, resizeTextarea]);
- // 댓글+답글 등록
- const handleSubmit = useCallback(async () => {
- if (!loginCheck()) {
- return;
- }
- if (!form.content.trim()) {
- if (boardMeta.list.layout === BoardLayout.QnA) {
- editorRef.current?.editorInstance?.editing.view.focus();
- } else {
- textareaRef.current?.focus();
- }
- return setError('내용을 입력해주세요.');
- }
- setLoading(true);
- try {
- {
- const trimmedContent = form.content.trim();
- const maxLen = boardMeta.comment.maxContentLength || CommentConst.MaxAllowedContentLength;
- const minLen = boardMeta.comment.minContentLength;
- if (minLen > 0 && trimmedContent.length < minLen) {
- if (boardMeta.comment.enableEditor) {
- editorRef.current?.editorInstance?.editing.view.focus();
- } else {
- textareaRef.current?.focus();
- }
- throw new Error(`내용은 ${minLen}자 이상 작성해주세요.`);
- }
- if (trimmedContent.length > maxLen) {
- if (boardMeta.comment.enableEditor) {
- editorRef.current?.editorInstance?.editing.view.focus();
- } else {
- textareaRef.current?.focus();
- }
- throw new Error(`내용은 ${maxLen}자 이내로 작성해주세요.`);
- }
- }
- const formData = new FormData();
- formData.append('postID', String(form.postID));
- formData.append('isSecret', String(form.isSecret));
- if (form.parentID) {
- formData.append('parentID', String(form.parentID));
- }
- // content에서 자동으로 붙은 @회원을 제거한다.
- let content = form.content;
- const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- if (form.mention) {
- content = content.replace(new RegExp('^\\s*' + escapeRegExp(form.mention) + '\\s*', 'i') , '');
- formData.append('mention', form.mention.trim());
- }
- // 1:1 문의일 경우 검사
- if (boardMeta.list.layout === BoardLayout.QnA) {
- if (content) {
- const doc = new DOMParser().parseFromString(content, 'text/html');
- doc.querySelectorAll('img[src]').forEach(img => {
- img.setAttribute('src', 'data:image/');
- });
- content = doc.body.innerHTML;
- }
- // 이미지 정보
- editorRef.current?.getImageStore().forEach((i) => {
- if (i.image?.size > 0 && i.name) {
- formData.append('images', i.image, i.name);
- }
- });
- // 미디어 정보
- editorRef.current?.getMediaStore().forEach((m) => {
- if (m.url) {
- formData.append('medias', m.url);
- }
- });
- // 첨부 파일
- editorRef.current?.getFileStore().forEach((f) => {
- if (f?.size > 0 && f.name) {
- formData.append('files', f.file, f.name);
- }
- });
- }
- formData.append('content', content);
- const res = await fetchApi<CommentCreateResponse>('/api/forum/comments', {
- method: 'POST',
- body: formData
- });
- const newComment: CommentItem = {
- id: res.data!.id,
- postID: _post.id,
- memberID: member!.id,
- parentID: form.parentID,
- writer: {
- id: member!.id,
- sid: member!.sid ?? '',
- name: member!.name,
- thumbnail: member!.thumb,
- icon: member!.icon,
- createdAt: ''
- },
- mention: form.mention ? { id: 0, rawHandle: form.mention } : undefined,
- content: form.content,
- isReply: !!form.parentID,
- isSecret: form.isSecret,
- likes: 0, dislikes: 0, reports: 0, replies: 0,
- hasLike: false, hasDislike: false, hasReport: false,
- createdAt: new Date().toISOString(),
- children: []
- };
- onSuccess(newComment);
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message);
- }
- } finally {
- setLoading(false);
- resetForm();
- }
- }, [form, boardMeta, _post.id, member, loginCheck, onSuccess, setError, resetForm]);
- // Ctrl + Enter 최종 제출
- const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
- if (e.ctrlKey && e.key === 'Enter') {
- e.preventDefault();
- handleSubmit();
- }
- }, [handleSubmit]);
- // 취소
- const handleCancel = useCallback(() => {
- if (onCancel) {
- onCancel();
- } else {
- resetForm();
- }
- }, [onCancel, resetForm]);
- const handleEditorChange = useCallback((data: string) => {
- setForm(prev => ({ ...prev, content: data }));
- }, []);
- return (
- <>
- {loading ? (
- <article>
- <Loading type={2} />
- </article>
- ) : (
- <article className='write-form'>
- {boardMeta.comment.showMemberThumb && (
- <div>
- <Image src={member?.thumb ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
- </div>
- )}
- <div>
- <div className='relative'>
- <MentionSuggestion postID={_post.id} textareaRef={textareaRef} onSelect={handleMentionSelect} />
- {boardMeta.comment.enableEditor ? (
- <Editor
- ref={editorRef}
- editorKey='_comment'
- data={form.content}
- onChange={handleEditorChange}
- boardMeta={boardMeta}
- />
- ) : (
- <textarea
- name='_comment'
- rows={1}
- value={form.content}
- title='댓글 입력 창'
- placeholder={boardMeta.comment.contentPlaceholder ?? ''}
- ref={textareaRef}
- onChange={handleContentChange}
- onKeyDown={handleKeyDown}
- disabled={loading}
- />
- )}
- </div>
- <div>
- <EmojiPicker onEmojiSelect={handleEmoji} />
- {boardMeta.comment.allowSecret && (
- <label className='ps-2'>
- <input type="checkbox" checked={form.isSecret} onChange={handleSecretChange} />
- <span className='ps-1'>비밀글</span>
- </label>
- )}
- </div>
- <div>
- <button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
- <button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
- </div>
- </div>
- </article>
- )}
- </>
- )
- };
|