FeedComposer.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. 'use client';
  2. import { useRef, useState } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { ImageIcon, X } from 'lucide-react';
  5. import { fetchApi } from '@/lib/utils/client';
  6. import useAuth from '@/hooks/useAuth';
  7. import EmojiPicker from '@/app/component/EmojiPicker';
  8. const MAX_LENGTH = 2000;
  9. const MAX_IMAGES = 4;
  10. type Props = {
  11. onSubmitted?: () => void;
  12. };
  13. export default function FeedComposer({ onSubmitted }: Props = {}) {
  14. const router = useRouter();
  15. const { member, loginCheck } = useAuth();
  16. const [content, setContent] = useState('');
  17. const [images, setImages] = useState<File[]>([]);
  18. const [previews, setPreviews] = useState<string[]>([]);
  19. const [submitting, setSubmitting] = useState(false);
  20. const [error, setError] = useState<string|null>(null);
  21. const fileInputRef = useRef<HTMLInputElement|null>(null);
  22. const textareaRef = useRef<HTMLTextAreaElement|null>(null);
  23. const isOver = content.length > MAX_LENGTH;
  24. const handleFilesSelected = (files: FileList|null) => {
  25. if (!files) {
  26. return;
  27. }
  28. const next: File[] = [...images];
  29. const nextPreviews: string[] = [...previews];
  30. for (const file of Array.from(files)) {
  31. if (next.length >= MAX_IMAGES) {
  32. break;
  33. }
  34. if (!file.type.startsWith('image/')) {
  35. continue;
  36. }
  37. next.push(file);
  38. nextPreviews.push(URL.createObjectURL(file));
  39. }
  40. setImages(next);
  41. setPreviews(nextPreviews);
  42. };
  43. const removeImage = (index: number) => {
  44. setImages((prev) => prev.filter((_, i) => i !== index));
  45. setPreviews((prev) => {
  46. const url = prev[index];
  47. if (url) {
  48. URL.revokeObjectURL(url);
  49. }
  50. return prev.filter((_, i) => i !== index);
  51. });
  52. };
  53. const handleEmojiSelect = (emoji: string) => {
  54. const textarea = textareaRef.current;
  55. if (!textarea) {
  56. setContent((prev) => prev + emoji);
  57. return;
  58. }
  59. const start = textarea.selectionStart ?? content.length;
  60. const end = textarea.selectionEnd ?? content.length;
  61. const next = content.slice(0, start) + emoji + content.slice(end);
  62. setContent(next);
  63. requestAnimationFrame(() => {
  64. textarea.focus();
  65. const caret = start + emoji.length;
  66. textarea.setSelectionRange(caret, caret);
  67. });
  68. };
  69. const handleSubmit = async () => {
  70. if (!loginCheck()) {
  71. return;
  72. }
  73. const trimmed = content.trim();
  74. if (trimmed.length === 0 && images.length === 0) {
  75. setError('내용을 입력하거나 이미지를 첨부해주세요.');
  76. return;
  77. }
  78. if (trimmed.length > MAX_LENGTH) {
  79. setError(`내용은 최대 ${MAX_LENGTH}자까지 작성할 수 있습니다.`);
  80. return;
  81. }
  82. setSubmitting(true);
  83. setError(null);
  84. try {
  85. const form = new FormData();
  86. form.append('Content', trimmed);
  87. for (const file of images) {
  88. form.append('images', file);
  89. }
  90. const res = await fetchApi<{ id: number }>('/api/feed', {
  91. method: 'POST',
  92. body: form,
  93. silent: true
  94. });
  95. if (!res.success) {
  96. setError(res.message ?? '게시 실패');
  97. return;
  98. }
  99. setContent('');
  100. setImages([]);
  101. setPreviews((prev) => {
  102. for (const url of prev) {
  103. URL.revokeObjectURL(url);
  104. }
  105. return [];
  106. });
  107. // FeedList 가 듣고 첫 페이지를 재조회해서 새 글을 prepend 함
  108. if (typeof window !== 'undefined') {
  109. window.dispatchEvent(new CustomEvent('feed:posted', { detail: { postID: res.data?.id } }));
  110. }
  111. if (onSubmitted) {
  112. onSubmitted();
  113. } else {
  114. router.push('/feed/all');
  115. }
  116. } catch (err) {
  117. console.error(err);
  118. setError(err instanceof Error ? err.message : '게시 실패');
  119. } finally {
  120. setSubmitting(false);
  121. }
  122. };
  123. if (!member) {
  124. return (
  125. <div className="feed__composer feed__composer--login">
  126. <p>글 작성을 위해 로그인이 필요합니다.</p>
  127. </div>
  128. );
  129. }
  130. return (
  131. <div className="feed__composer">
  132. <div className="feed__composer-avatar">
  133. {member.thumb ? (
  134. <img src={member.thumb} alt={member.name ?? member.sid} />
  135. ) : (
  136. <span className="feed__composer-avatar-fallback">{(member.name ?? member.sid).charAt(0).toUpperCase()}</span>
  137. )}
  138. </div>
  139. <div className="feed__composer-body">
  140. <textarea
  141. ref={textareaRef}
  142. id="feed-composer-content"
  143. name="content"
  144. className="feed__composer-input"
  145. placeholder="무슨 일이 일어나고 있나요? #태그 @멘션 가능"
  146. value={content}
  147. onChange={(e) => setContent(e.target.value)}
  148. rows={3}
  149. maxLength={MAX_LENGTH + 100}
  150. disabled={submitting}
  151. />
  152. {previews.length > 0 && (
  153. <div className="feed__composer-previews">
  154. {previews.map((url, index) => (
  155. <div key={url} className="feed__composer-preview">
  156. <img src={url} alt={`첨부 ${index + 1}`} />
  157. <button type="button" className="feed__composer-preview-remove" onClick={() => removeImage(index)} aria-label="첨부 제거">
  158. <X size={14} />
  159. </button>
  160. </div>
  161. ))}
  162. </div>
  163. )}
  164. {error && <p className="feed__composer-error">{error}</p>}
  165. <div className="feed__composer-footer">
  166. <div className="feed__composer-tools">
  167. <EmojiPicker onEmojiSelect={handleEmojiSelect} />
  168. <button
  169. type="button"
  170. onClick={() => fileInputRef.current?.click()}
  171. disabled={submitting || images.length >= MAX_IMAGES}
  172. className="feed__composer-tool"
  173. aria-label="이미지 첨부"
  174. >
  175. <ImageIcon size={18} />
  176. </button>
  177. <input
  178. ref={fileInputRef}
  179. type="file"
  180. accept="image/*"
  181. multiple
  182. hidden
  183. onChange={(e) => handleFilesSelected(e.target.files)}
  184. />
  185. </div>
  186. <div className="feed__composer-submit">
  187. <span className={`feed__composer-count${isOver ? ' feed__composer-count--over' : ''}`}>
  188. {content.length.toLocaleString()} / {MAX_LENGTH.toLocaleString()}
  189. </span>
  190. <button
  191. type="button"
  192. onClick={handleSubmit}
  193. disabled={submitting || (content.trim().length === 0 && images.length === 0) || isOver}
  194. className="feed__composer-submit-btn"
  195. >
  196. {submitting ? '게시 중...' : '게시하기'}
  197. </button>
  198. </div>
  199. </div>
  200. </div>
  201. </div>
  202. );
  203. }