NoteComposeModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. 'use client';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import type { RecipientItem, SearchRecipientResponse } from '@/types/response/note/searchRecipient';
  6. import { Search, X } from 'lucide-react';
  7. type Props = {
  8. open: boolean;
  9. onClose: () => void;
  10. initialReceiver?: RecipientItem|null;
  11. initialTitle?: string;
  12. onSent?: () => void;
  13. };
  14. const PAGE_SIZE = 20;
  15. const SEARCH_DEBOUNCE_MS = 300;
  16. export default function NoteComposeModal({ open, onClose, initialReceiver, initialTitle, onSent }: Props)
  17. {
  18. const [receiver, setReceiver] = useState<RecipientItem|null>(null);
  19. const [searchQuery, setSearchQuery] = useState('');
  20. const [searchResults, setSearchResults] = useState<RecipientItem[]>([]);
  21. const [searchHasMore, setSearchHasMore] = useState(false);
  22. const [searchOffset, setSearchOffset] = useState(0);
  23. const [searchLoading, setSearchLoading] = useState(false);
  24. const searchTimerRef = useRef<ReturnType<typeof setTimeout>|null>(null);
  25. const [title, setTitle] = useState('');
  26. const [content, setContent] = useState('');
  27. const [sendToRandom, setSendToRandom] = useState(false);
  28. const [sending, setSending] = useState(false);
  29. const [error, setError] = useState<string|null>(null);
  30. // 모달 열릴 때 상태 초기화
  31. useEffect(() => {
  32. if (open) {
  33. setReceiver(initialReceiver ?? null);
  34. setTitle(initialTitle ?? '');
  35. setContent('');
  36. setSearchQuery('');
  37. setSearchResults([]);
  38. setSearchHasMore(false);
  39. setSearchOffset(0);
  40. setError(null);
  41. setSendToRandom(false);
  42. }
  43. }, [open, initialReceiver, initialTitle]);
  44. const runSearch = useCallback(async (q: string, offset: number, append: boolean) => {
  45. const trimmed = q.trim();
  46. if (trimmed.length < 2) {
  47. setSearchResults([]);
  48. setSearchHasMore(false);
  49. setSearchOffset(0);
  50. return;
  51. }
  52. setSearchLoading(true);
  53. try {
  54. const params = new URLSearchParams({ q: trimmed, offset: String(offset), limit: String(PAGE_SIZE) });
  55. const res = await fetchApi<SearchRecipientResponse>(`/api/note/search-recipient?${params.toString()}`, { silent: true });
  56. if (res.data) {
  57. setSearchResults(prev => append ? [...prev, ...res.data!.list] : res.data!.list);
  58. setSearchHasMore(res.data.hasMore);
  59. setSearchOffset(offset + res.data.list.length);
  60. }
  61. } catch {
  62. // ignore — silent
  63. } finally {
  64. setSearchLoading(false);
  65. }
  66. }, []);
  67. // 검색 query 변경 시 debounce
  68. useEffect(() => {
  69. if (receiver) {
  70. return;
  71. }
  72. if (searchTimerRef.current) {
  73. clearTimeout(searchTimerRef.current);
  74. }
  75. searchTimerRef.current = setTimeout(() => {
  76. runSearch(searchQuery, 0, false);
  77. }, SEARCH_DEBOUNCE_MS);
  78. return () => {
  79. if (searchTimerRef.current) {
  80. clearTimeout(searchTimerRef.current);
  81. }
  82. };
  83. }, [searchQuery, receiver, runSearch]);
  84. const handleShowMore = () => {
  85. runSearch(searchQuery, searchOffset, true);
  86. };
  87. const handleSelect = (item: RecipientItem) => {
  88. setReceiver(item);
  89. setSearchQuery('');
  90. setSearchResults([]);
  91. setSearchHasMore(false);
  92. };
  93. const handleClearReceiver = () => {
  94. setReceiver(null);
  95. };
  96. const handleSend = async () => {
  97. setError(null);
  98. if (!sendToRandom && !receiver) {
  99. setError('받는 사람을 선택해주세요.');
  100. return;
  101. }
  102. if (!title.trim() || !content.trim()) {
  103. setError('제목과 내용을 입력해주세요.');
  104. return;
  105. }
  106. setSending(true);
  107. try {
  108. const res = await fetchApi('/api/note/send', {
  109. method: 'POST',
  110. body: sendToRandom
  111. ? {
  112. title: title.trim(),
  113. content: content.trim(),
  114. sendToRandom: true
  115. }
  116. : {
  117. receiverMemberID: receiver!.memberID,
  118. title: title.trim(),
  119. content: content.trim()
  120. }
  121. });
  122. if (res.success) {
  123. onSent?.();
  124. onClose();
  125. return;
  126. }
  127. setError(res.message || '전송에 실패했습니다.');
  128. } catch (e: unknown) {
  129. const message = e instanceof Error ? e.message : '전송에 실패했습니다.';
  130. setError(message);
  131. } finally {
  132. setSending(false);
  133. }
  134. };
  135. return (
  136. <Dialog open={open} onOpenChange={(o) => { if (!o) { onClose(); } }}>
  137. <DialogContent className="max-w-md">
  138. <DialogHeader>
  139. <DialogTitle>{initialTitle?.startsWith('RE:') ? '답장' : '쪽지 보내기'}</DialogTitle>
  140. </DialogHeader>
  141. <div className="flex flex-col gap-3 mt-2">
  142. {/* 무작위 발송 (쪽지권) */}
  143. <label className="flex items-center gap-2 text-sm">
  144. <input type="checkbox" checked={sendToRandom} onChange={(e) => setSendToRandom(e.target.checked)} />
  145. <span>무작위 회원에게 발송 (쪽지권 1개 소모)</span>
  146. </label>
  147. {sendToRandom && (
  148. <div className="text-xs text-gray-500">받는 사람은 무작위로 선택되며, 쪽지권 1개가 사용됩니다.</div>
  149. )}
  150. {/* 받는 사람 */}
  151. <div>
  152. <label className="text-sm font-medium mb-1 block">받는 사람</label>
  153. {receiver ? (
  154. <div className="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700">
  155. {receiver.thumbnailUrl ? (
  156. // eslint-disable-next-line @next/next/no-img-element
  157. <img src={receiver.thumbnailUrl} alt={receiver.displayName} className="w-8 h-8 rounded-full object-cover" />
  158. ) : (
  159. <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs">
  160. {receiver.displayName[0]?.toUpperCase() ?? '?'}
  161. </div>
  162. )}
  163. <div className="flex-1 overflow-hidden">
  164. <div className="text-sm font-medium truncate">{receiver.displayName}</div>
  165. {receiver.displaySubtitle && (
  166. <div className="text-xs text-gray-500 truncate">{receiver.displaySubtitle}</div>
  167. )}
  168. </div>
  169. <button
  170. type="button"
  171. onClick={handleClearReceiver}
  172. className="p-1 text-gray-400 hover:text-gray-600"
  173. aria-label="수신자 변경"
  174. >
  175. <X size={16} />
  176. </button>
  177. </div>
  178. ) : (
  179. <>
  180. <div className="relative">
  181. <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
  182. <input
  183. type="text"
  184. value={searchQuery}
  185. onChange={(e) => setSearchQuery(e.target.value)}
  186. placeholder="팔로우한 회원 검색"
  187. className="w-full border rounded-lg pl-9 pr-3 py-2 text-sm"
  188. />
  189. </div>
  190. {searchQuery.trim().length >= 2 && (
  191. <div className="mt-1 border rounded-lg max-h-60 overflow-y-auto">
  192. {searchLoading && searchResults.length === 0 ? (
  193. <div className="p-3 text-xs text-gray-500 text-center">검색 중…</div>
  194. ) : searchResults.length === 0 ? (
  195. <div className="p-3 text-xs text-gray-500 text-center">
  196. 검색 결과가 없습니다. 팔로우한 회원만 검색됩니다.
  197. </div>
  198. ) : (
  199. <>
  200. {searchResults.map(item => (
  201. <button
  202. key={item.memberID}
  203. type="button"
  204. onClick={() => handleSelect(item)}
  205. className="w-full flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-800 text-left"
  206. >
  207. {item.thumbnailUrl ? (
  208. // eslint-disable-next-line @next/next/no-img-element
  209. <img src={item.thumbnailUrl} alt={item.displayName} className="w-7 h-7 rounded-full object-cover" />
  210. ) : (
  211. <div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs">
  212. {item.displayName[0]?.toUpperCase() ?? '?'}
  213. </div>
  214. )}
  215. <div className="flex-1 overflow-hidden">
  216. <div className="text-sm truncate">{item.displayName}</div>
  217. {item.displaySubtitle && (
  218. <div className="text-xs text-gray-500 truncate">{item.displaySubtitle}</div>
  219. )}
  220. </div>
  221. </button>
  222. ))}
  223. {searchHasMore && (
  224. <button
  225. type="button"
  226. onClick={handleShowMore}
  227. disabled={searchLoading}
  228. className="w-full p-2 text-xs text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
  229. >
  230. {searchLoading ? '불러오는 중…' : '더 보기'}
  231. </button>
  232. )}
  233. </>
  234. )}
  235. </div>
  236. )}
  237. </>
  238. )}
  239. </div>
  240. {/* 제목 */}
  241. <div>
  242. <label className="text-sm font-medium mb-1 block">제목</label>
  243. <input
  244. type="text"
  245. value={title}
  246. onChange={(e) => setTitle(e.target.value)}
  247. maxLength={200}
  248. placeholder="제목을 입력하세요"
  249. className="w-full border rounded-lg px-3 py-2 text-sm"
  250. />
  251. </div>
  252. {/* 내용 */}
  253. <div>
  254. <label className="text-sm font-medium mb-1 block">내용</label>
  255. <textarea
  256. value={content}
  257. onChange={(e) => setContent(e.target.value)}
  258. maxLength={2000}
  259. rows={6}
  260. placeholder="내용을 입력하세요"
  261. className="w-full border rounded-lg px-3 py-2 text-sm resize-none"
  262. />
  263. <div className="text-xs text-gray-400 text-right mt-1">{content.length}/2000</div>
  264. </div>
  265. {error && (
  266. <div className="p-2 rounded-lg bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 text-xs">
  267. {error}
  268. </div>
  269. )}
  270. <div className="flex gap-2 mt-2">
  271. <button
  272. type="button"
  273. onClick={onClose}
  274. disabled={sending}
  275. className="flex-1 border rounded-lg py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-50"
  276. >
  277. 취소
  278. </button>
  279. <button
  280. type="button"
  281. onClick={handleSend}
  282. disabled={sending}
  283. className="flex-1 bg-blue-500 text-white rounded-lg py-2 text-sm hover:bg-blue-600 disabled:opacity-50"
  284. >
  285. {sending ? '전송 중…' : '보내기'}
  286. </button>
  287. </div>
  288. </div>
  289. </DialogContent>
  290. </Dialog>
  291. );
  292. }