| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- 'use client';
- import { useRef, useState } from 'react';
- import { useRouter } from 'next/navigation';
- import { ImageIcon, X } from 'lucide-react';
- import { fetchApi } from '@/lib/utils/client';
- import useAuth from '@/hooks/useAuth';
- import EmojiPicker from '@/app/component/EmojiPicker';
- const MAX_LENGTH = 2000;
- const MAX_IMAGES = 4;
- type Props = {
- onSubmitted?: () => void;
- };
- export default function FeedComposer({ onSubmitted }: Props = {}) {
- const router = useRouter();
- const { member, loginCheck } = useAuth();
- const [content, setContent] = useState('');
- const [images, setImages] = useState<File[]>([]);
- const [previews, setPreviews] = useState<string[]>([]);
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState<string|null>(null);
- const fileInputRef = useRef<HTMLInputElement|null>(null);
- const textareaRef = useRef<HTMLTextAreaElement|null>(null);
- const isOver = content.length > MAX_LENGTH;
- const handleFilesSelected = (files: FileList|null) => {
- if (!files) {
- return;
- }
- const next: File[] = [...images];
- const nextPreviews: string[] = [...previews];
- for (const file of Array.from(files)) {
- if (next.length >= MAX_IMAGES) {
- break;
- }
- if (!file.type.startsWith('image/')) {
- continue;
- }
- next.push(file);
- nextPreviews.push(URL.createObjectURL(file));
- }
- setImages(next);
- setPreviews(nextPreviews);
- };
- const removeImage = (index: number) => {
- setImages((prev) => prev.filter((_, i) => i !== index));
- setPreviews((prev) => {
- const url = prev[index];
- if (url) {
- URL.revokeObjectURL(url);
- }
- return prev.filter((_, i) => i !== index);
- });
- };
- const handleEmojiSelect = (emoji: string) => {
- const textarea = textareaRef.current;
- if (!textarea) {
- setContent((prev) => prev + emoji);
- return;
- }
- const start = textarea.selectionStart ?? content.length;
- const end = textarea.selectionEnd ?? content.length;
- const next = content.slice(0, start) + emoji + content.slice(end);
- setContent(next);
- requestAnimationFrame(() => {
- textarea.focus();
- const caret = start + emoji.length;
- textarea.setSelectionRange(caret, caret);
- });
- };
- const handleSubmit = async () => {
- if (!loginCheck()) {
- return;
- }
- const trimmed = content.trim();
- if (trimmed.length === 0 && images.length === 0) {
- setError('내용을 입력하거나 이미지를 첨부해주세요.');
- return;
- }
- if (trimmed.length > MAX_LENGTH) {
- setError(`내용은 최대 ${MAX_LENGTH}자까지 작성할 수 있습니다.`);
- return;
- }
- setSubmitting(true);
- setError(null);
- try {
- const form = new FormData();
- form.append('Content', trimmed);
- for (const file of images) {
- form.append('images', file);
- }
- const res = await fetchApi<{ id: number }>('/api/feed', {
- method: 'POST',
- body: form,
- silent: true
- });
- if (!res.success) {
- setError(res.message ?? '게시 실패');
- return;
- }
- setContent('');
- setImages([]);
- setPreviews((prev) => {
- for (const url of prev) {
- URL.revokeObjectURL(url);
- }
- return [];
- });
- // FeedList 가 듣고 첫 페이지를 재조회해서 새 글을 prepend 함
- if (typeof window !== 'undefined') {
- window.dispatchEvent(new CustomEvent('feed:posted', { detail: { postID: res.data?.id } }));
- }
- if (onSubmitted) {
- onSubmitted();
- } else {
- router.push('/feed/all');
- }
- } catch (err) {
- console.error(err);
- setError(err instanceof Error ? err.message : '게시 실패');
- } finally {
- setSubmitting(false);
- }
- };
- if (!member) {
- return (
- <div className="feed__composer feed__composer--login">
- <p>글 작성을 위해 로그인이 필요합니다.</p>
- </div>
- );
- }
- return (
- <div className="feed__composer">
- <div className="feed__composer-avatar">
- {member.thumb ? (
- <img src={member.thumb} alt={member.name ?? member.sid} />
- ) : (
- <span className="feed__composer-avatar-fallback">{(member.name ?? member.sid).charAt(0).toUpperCase()}</span>
- )}
- </div>
- <div className="feed__composer-body">
- <textarea
- ref={textareaRef}
- id="feed-composer-content"
- name="content"
- className="feed__composer-input"
- placeholder="무슨 일이 일어나고 있나요? #태그 @멘션 가능"
- value={content}
- onChange={(e) => setContent(e.target.value)}
- rows={3}
- maxLength={MAX_LENGTH + 100}
- disabled={submitting}
- />
- {previews.length > 0 && (
- <div className="feed__composer-previews">
- {previews.map((url, index) => (
- <div key={url} className="feed__composer-preview">
- <img src={url} alt={`첨부 ${index + 1}`} />
- <button type="button" className="feed__composer-preview-remove" onClick={() => removeImage(index)} aria-label="첨부 제거">
- <X size={14} />
- </button>
- </div>
- ))}
- </div>
- )}
- {error && <p className="feed__composer-error">{error}</p>}
- <div className="feed__composer-footer">
- <div className="feed__composer-tools">
- <EmojiPicker onEmojiSelect={handleEmojiSelect} />
- <button
- type="button"
- onClick={() => fileInputRef.current?.click()}
- disabled={submitting || images.length >= MAX_IMAGES}
- className="feed__composer-tool"
- aria-label="이미지 첨부"
- >
- <ImageIcon size={18} />
- </button>
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*"
- multiple
- hidden
- onChange={(e) => handleFilesSelected(e.target.files)}
- />
- </div>
- <div className="feed__composer-submit">
- <span className={`feed__composer-count${isOver ? ' feed__composer-count--over' : ''}`}>
- {content.length.toLocaleString()} / {MAX_LENGTH.toLocaleString()}
- </span>
- <button
- type="button"
- onClick={handleSubmit}
- disabled={submitting || (content.trim().length === 0 && images.length === 0) || isOver}
- className="feed__composer-submit-btn"
- >
- {submitting ? '게시 중...' : '게시하기'}
- </button>
- </div>
- </div>
- </div>
- </div>
- );
- }
|