| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- 'use client';
- import { useCallback, useEffect, useRef, useState } from 'react';
- import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
- import { fetchApi } from '@/lib/utils/client';
- import type { RecipientItem, SearchRecipientResponse } from '@/types/response/note/searchRecipient';
- import { Search, X } from 'lucide-react';
- type Props = {
- open: boolean;
- onClose: () => void;
- initialReceiver?: RecipientItem|null;
- initialTitle?: string;
- onSent?: () => void;
- };
- const PAGE_SIZE = 20;
- const SEARCH_DEBOUNCE_MS = 300;
- export default function NoteComposeModal({ open, onClose, initialReceiver, initialTitle, onSent }: Props)
- {
- const [receiver, setReceiver] = useState<RecipientItem|null>(null);
- const [searchQuery, setSearchQuery] = useState('');
- const [searchResults, setSearchResults] = useState<RecipientItem[]>([]);
- const [searchHasMore, setSearchHasMore] = useState(false);
- const [searchOffset, setSearchOffset] = useState(0);
- const [searchLoading, setSearchLoading] = useState(false);
- const searchTimerRef = useRef<ReturnType<typeof setTimeout>|null>(null);
- const [title, setTitle] = useState('');
- const [content, setContent] = useState('');
- const [sendToRandom, setSendToRandom] = useState(false);
- const [sending, setSending] = useState(false);
- const [error, setError] = useState<string|null>(null);
- // 모달 열릴 때 상태 초기화
- useEffect(() => {
- if (open) {
- setReceiver(initialReceiver ?? null);
- setTitle(initialTitle ?? '');
- setContent('');
- setSearchQuery('');
- setSearchResults([]);
- setSearchHasMore(false);
- setSearchOffset(0);
- setError(null);
- setSendToRandom(false);
- }
- }, [open, initialReceiver, initialTitle]);
- const runSearch = useCallback(async (q: string, offset: number, append: boolean) => {
- const trimmed = q.trim();
- if (trimmed.length < 2) {
- setSearchResults([]);
- setSearchHasMore(false);
- setSearchOffset(0);
- return;
- }
- setSearchLoading(true);
- try {
- const params = new URLSearchParams({ q: trimmed, offset: String(offset), limit: String(PAGE_SIZE) });
- const res = await fetchApi<SearchRecipientResponse>(`/api/note/search-recipient?${params.toString()}`, { silent: true });
- if (res.data) {
- setSearchResults(prev => append ? [...prev, ...res.data!.list] : res.data!.list);
- setSearchHasMore(res.data.hasMore);
- setSearchOffset(offset + res.data.list.length);
- }
- } catch {
- // ignore — silent
- } finally {
- setSearchLoading(false);
- }
- }, []);
- // 검색 query 변경 시 debounce
- useEffect(() => {
- if (receiver) {
- return;
- }
- if (searchTimerRef.current) {
- clearTimeout(searchTimerRef.current);
- }
- searchTimerRef.current = setTimeout(() => {
- runSearch(searchQuery, 0, false);
- }, SEARCH_DEBOUNCE_MS);
- return () => {
- if (searchTimerRef.current) {
- clearTimeout(searchTimerRef.current);
- }
- };
- }, [searchQuery, receiver, runSearch]);
- const handleShowMore = () => {
- runSearch(searchQuery, searchOffset, true);
- };
- const handleSelect = (item: RecipientItem) => {
- setReceiver(item);
- setSearchQuery('');
- setSearchResults([]);
- setSearchHasMore(false);
- };
- const handleClearReceiver = () => {
- setReceiver(null);
- };
- const handleSend = async () => {
- setError(null);
- if (!sendToRandom && !receiver) {
- setError('받는 사람을 선택해주세요.');
- return;
- }
- if (!title.trim() || !content.trim()) {
- setError('제목과 내용을 입력해주세요.');
- return;
- }
- setSending(true);
- try {
- const res = await fetchApi('/api/note/send', {
- method: 'POST',
- body: sendToRandom
- ? {
- title: title.trim(),
- content: content.trim(),
- sendToRandom: true
- }
- : {
- receiverMemberID: receiver!.memberID,
- title: title.trim(),
- content: content.trim()
- }
- });
- if (res.success) {
- onSent?.();
- onClose();
- return;
- }
- setError(res.message || '전송에 실패했습니다.');
- } catch (e: unknown) {
- const message = e instanceof Error ? e.message : '전송에 실패했습니다.';
- setError(message);
- } finally {
- setSending(false);
- }
- };
- return (
- <Dialog open={open} onOpenChange={(o) => { if (!o) { onClose(); } }}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle>{initialTitle?.startsWith('RE:') ? '답장' : '쪽지 보내기'}</DialogTitle>
- </DialogHeader>
- <div className="flex flex-col gap-3 mt-2">
- {/* 무작위 발송 (쪽지권) */}
- <label className="flex items-center gap-2 text-sm">
- <input type="checkbox" checked={sendToRandom} onChange={(e) => setSendToRandom(e.target.checked)} />
- <span>무작위 회원에게 발송 (쪽지권 1개 소모)</span>
- </label>
- {sendToRandom && (
- <div className="text-xs text-gray-500">받는 사람은 무작위로 선택되며, 쪽지권 1개가 사용됩니다.</div>
- )}
- {/* 받는 사람 */}
- <div>
- <label className="text-sm font-medium mb-1 block">받는 사람</label>
- {receiver ? (
- <div className="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700">
- {receiver.thumbnailUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={receiver.thumbnailUrl} alt={receiver.displayName} className="w-8 h-8 rounded-full object-cover" />
- ) : (
- <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs">
- {receiver.displayName[0]?.toUpperCase() ?? '?'}
- </div>
- )}
- <div className="flex-1 overflow-hidden">
- <div className="text-sm font-medium truncate">{receiver.displayName}</div>
- {receiver.displaySubtitle && (
- <div className="text-xs text-gray-500 truncate">{receiver.displaySubtitle}</div>
- )}
- </div>
- <button
- type="button"
- onClick={handleClearReceiver}
- className="p-1 text-gray-400 hover:text-gray-600"
- aria-label="수신자 변경"
- >
- <X size={16} />
- </button>
- </div>
- ) : (
- <>
- <div className="relative">
- <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
- <input
- type="text"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- placeholder="팔로우한 회원/채널 검색"
- className="w-full border rounded-lg pl-9 pr-3 py-2 text-sm"
- />
- </div>
- {searchQuery.trim().length >= 2 && (
- <div className="mt-1 border rounded-lg max-h-60 overflow-y-auto">
- {searchLoading && searchResults.length === 0 ? (
- <div className="p-3 text-xs text-gray-500 text-center">검색 중…</div>
- ) : searchResults.length === 0 ? (
- <div className="p-3 text-xs text-gray-500 text-center">
- 검색 결과가 없습니다. 팔로우한 회원만 검색됩니다.
- </div>
- ) : (
- <>
- {searchResults.map(item => (
- <button
- key={item.memberID}
- type="button"
- onClick={() => handleSelect(item)}
- className="w-full flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-800 text-left"
- >
- {item.thumbnailUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={item.thumbnailUrl} alt={item.displayName} className="w-7 h-7 rounded-full object-cover" />
- ) : (
- <div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs">
- {item.displayName[0]?.toUpperCase() ?? '?'}
- </div>
- )}
- <div className="flex-1 overflow-hidden">
- <div className="text-sm truncate">{item.displayName}</div>
- {item.displaySubtitle && (
- <div className="text-xs text-gray-500 truncate">{item.displaySubtitle}</div>
- )}
- </div>
- </button>
- ))}
- {searchHasMore && (
- <button
- type="button"
- onClick={handleShowMore}
- disabled={searchLoading}
- className="w-full p-2 text-xs text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
- >
- {searchLoading ? '불러오는 중…' : '더 보기'}
- </button>
- )}
- </>
- )}
- </div>
- )}
- </>
- )}
- </div>
- {/* 제목 */}
- <div>
- <label className="text-sm font-medium mb-1 block">제목</label>
- <input
- type="text"
- value={title}
- onChange={(e) => setTitle(e.target.value)}
- maxLength={200}
- placeholder="제목을 입력하세요"
- className="w-full border rounded-lg px-3 py-2 text-sm"
- />
- </div>
- {/* 내용 */}
- <div>
- <label className="text-sm font-medium mb-1 block">내용</label>
- <textarea
- value={content}
- onChange={(e) => setContent(e.target.value)}
- maxLength={2000}
- rows={6}
- placeholder="내용을 입력하세요"
- className="w-full border rounded-lg px-3 py-2 text-sm resize-none"
- />
- <div className="text-xs text-gray-400 text-right mt-1">{content.length}/2000</div>
- </div>
- {error && (
- <div className="p-2 rounded-lg bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 text-xs">
- {error}
- </div>
- )}
- <div className="flex gap-2 mt-2">
- <button
- type="button"
- onClick={onClose}
- disabled={sending}
- className="flex-1 border rounded-lg py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-50"
- >
- 취소
- </button>
- <button
- type="button"
- onClick={handleSend}
- disabled={sending}
- className="flex-1 bg-blue-500 text-white rounded-lg py-2 text-sm hover:bg-blue-600 disabled:opacity-50"
- >
- {sending ? '전송 중…' : '보내기'}
- </button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- );
- }
|