NoteComposeModal.tsx 9.4 KB

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