NoteDetailModal.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import { formatNoteCounterparty } from '@/lib/utils/note';
  6. import Loading from '@/app/component/Loading';
  7. import { useMemberContext } from '@/contexts/memberProvider';
  8. import type { NoteDetail } from '@/types/response/note/inbox';
  9. import type { RecipientItem } from '@/types/response/note/searchRecipient';
  10. import { Reply, Trash2 } from 'lucide-react';
  11. type Props = {
  12. noteId: number|null;
  13. onClose: () => void;
  14. onReply?: (sender: RecipientItem, originalTitle: string) => void;
  15. onDeleted?: () => void;
  16. };
  17. function formatDate(dateStr: string): string {
  18. const d = new Date(dateStr);
  19. return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
  20. }
  21. export default function NoteDetailModal({ noteId, onClose, onReply, onDeleted }: Props)
  22. {
  23. const { member } = useMemberContext();
  24. const [note, setNote] = useState<NoteDetail|null>(null);
  25. const [loading, setLoading] = useState(false);
  26. const [deleting, setDeleting] = useState(false);
  27. const [error, setError] = useState<string|null>(null);
  28. useEffect(() => {
  29. if (noteId === null) {
  30. setNote(null);
  31. setError(null);
  32. return;
  33. }
  34. let cancelled = false;
  35. setLoading(true);
  36. setError(null);
  37. fetchApi<NoteDetail>(`/api/note/${noteId}`, { silent: true }).then((res) => {
  38. if (cancelled) {
  39. return;
  40. }
  41. if (res.success && res.data) {
  42. setNote(res.data);
  43. } else {
  44. setError(res.message || '쪽지를 불러올 수 없습니다.');
  45. }
  46. }).catch((e: unknown) => {
  47. if (cancelled) {
  48. return;
  49. }
  50. const msg = e instanceof Error ? e.message : '쪽지를 불러올 수 없습니다.';
  51. setError(msg);
  52. }).finally(() => {
  53. if (!cancelled) {
  54. setLoading(false);
  55. }
  56. });
  57. return () => {
  58. cancelled = true;
  59. };
  60. }, [noteId]);
  61. // 본인이 sender 인 경우 보낸쪽지 모드 — 답장 버튼 숨기고 상대방으로 receiver 표시
  62. const isSenderView = note !== null && member !== null && note.senderMemberID === member.id;
  63. const counterpartyThumb = isSenderView ? note?.receiverThumb : note?.senderThumb;
  64. const counterpartyLabel = isSenderView ? '받는 사람' : '보낸 사람';
  65. const counterparty = note ? formatNoteCounterparty(isSenderView ? {
  66. name: note.receiverName,
  67. sid: note.receiverSID,
  68. channelName: note.receiverChannelName,
  69. channelHandle: note.receiverChannelHandle
  70. } : {
  71. isSystem: note.isSystem,
  72. name: note.senderName,
  73. sid: note.senderSID,
  74. channelName: note.senderChannelName,
  75. channelHandle: note.senderChannelHandle
  76. }) : { primary: '', secondary: null };
  77. const counterpartyName = counterparty.primary;
  78. const handleReply = () => {
  79. if (!note || note.isSystem || !note.senderMemberID) {
  80. return;
  81. }
  82. const sender: RecipientItem = {
  83. memberID: note.senderMemberID,
  84. displayName: note.senderName ?? '회원',
  85. displaySubtitle: null,
  86. thumbnailUrl: note.senderThumb
  87. };
  88. onReply?.(sender, note.title);
  89. };
  90. const handleDelete = async () => {
  91. if (!note) {
  92. return;
  93. }
  94. if (!confirm('이 쪽지를 삭제하시겠습니까?')) {
  95. return;
  96. }
  97. setDeleting(true);
  98. try {
  99. const res = await fetchApi(`/api/note/${note.id}`, { method: 'DELETE' });
  100. if (res.success) {
  101. onDeleted?.();
  102. onClose();
  103. return;
  104. }
  105. setError(res.message || '삭제에 실패했습니다.');
  106. } catch (e: unknown) {
  107. const msg = e instanceof Error ? e.message : '삭제에 실패했습니다.';
  108. setError(msg);
  109. } finally {
  110. setDeleting(false);
  111. }
  112. };
  113. const open = noteId !== null;
  114. const canReply = !isSenderView && note !== null && !note.isSystem && note.senderMemberID !== null;
  115. return (
  116. <Dialog open={open} onOpenChange={(o) => { if (!o) { onClose(); } }}>
  117. <DialogContent className="max-w-md">
  118. <DialogHeader>
  119. <DialogTitle>{note?.title ?? '쪽지'}</DialogTitle>
  120. </DialogHeader>
  121. <div className="mt-2">
  122. {loading && <Loading type={2} />}
  123. {error && (
  124. <div className="p-2 rounded-lg bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 text-xs">
  125. {error}
  126. </div>
  127. )}
  128. {note && !loading && (
  129. <>
  130. <div className="flex items-center gap-2 mb-3">
  131. {counterpartyThumb ? (
  132. // eslint-disable-next-line @next/next/no-img-element
  133. <img src={counterpartyThumb} alt={counterpartyName} className="w-7 h-7 rounded-full object-cover" />
  134. ) : (
  135. <div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs">
  136. {(counterpartyName ?? '?')[0]?.toUpperCase()}
  137. </div>
  138. )}
  139. <div className="flex-1">
  140. <div className="text-sm font-medium">
  141. <span className="text-xs text-gray-500 mr-1">{counterpartyLabel}:</span>
  142. {counterpartyName}
  143. {counterparty.secondary && (
  144. <span className="ml-1 text-xs text-gray-500">{counterparty.secondary}</span>
  145. )}
  146. {note.isSystem && (
  147. <span className="ml-2 text-xs bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">시스템</span>
  148. )}
  149. {isSenderView && (
  150. <span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${note.isRead ? 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300' : 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}>
  151. {note.isRead ? '읽음' : '안읽음'}
  152. </span>
  153. )}
  154. </div>
  155. <div className="text-xs text-gray-500">{formatDate(note.createdAt)}</div>
  156. </div>
  157. </div>
  158. <div className="text-sm whitespace-pre-wrap mb-4 max-h-72 overflow-y-auto">
  159. {note.content}
  160. </div>
  161. <div className="flex gap-2">
  162. {canReply && (
  163. <button
  164. type="button"
  165. onClick={handleReply}
  166. disabled={deleting}
  167. className="flex-1 flex items-center justify-center gap-1 border rounded-lg py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-50"
  168. >
  169. <Reply size={14} /> 답장
  170. </button>
  171. )}
  172. <button
  173. type="button"
  174. onClick={handleDelete}
  175. disabled={deleting}
  176. className="flex-1 flex items-center justify-center gap-1 border rounded-lg py-2 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
  177. >
  178. <Trash2 size={14} /> {deleting ? '삭제 중…' : '삭제'}
  179. </button>
  180. </div>
  181. </>
  182. )}
  183. </div>
  184. </DialogContent>
  185. </Dialog>
  186. );
  187. }