| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- 'use client';
- import { useEffect, useState } from 'react';
- import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
- import { fetchApi } from '@/lib/utils/client';
- import { formatNoteCounterparty } from '@/lib/utils/note';
- import Loading from '@/app/component/Loading';
- import { useMemberContext } from '@/contexts/memberProvider';
- import type { NoteDetail } from '@/types/response/note/inbox';
- import type { RecipientItem } from '@/types/response/note/searchRecipient';
- import { Reply, Trash2 } from 'lucide-react';
- type Props = {
- noteId: number|null;
- onClose: () => void;
- onReply?: (sender: RecipientItem, originalTitle: string) => void;
- onDeleted?: () => void;
- };
- function formatDate(dateStr: string): string {
- const d = new Date(dateStr);
- 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')}`;
- }
- export default function NoteDetailModal({ noteId, onClose, onReply, onDeleted }: Props)
- {
- const { member } = useMemberContext();
- const [note, setNote] = useState<NoteDetail|null>(null);
- const [loading, setLoading] = useState(false);
- const [deleting, setDeleting] = useState(false);
- const [error, setError] = useState<string|null>(null);
- useEffect(() => {
- if (noteId === null) {
- setNote(null);
- setError(null);
- return;
- }
- let cancelled = false;
- setLoading(true);
- setError(null);
- fetchApi<NoteDetail>(`/api/note/${noteId}`, { silent: true }).then((res) => {
- if (cancelled) {
- return;
- }
- if (res.success && res.data) {
- setNote(res.data);
- } else {
- setError(res.message || '쪽지를 불러올 수 없습니다.');
- }
- }).catch((e: unknown) => {
- if (cancelled) {
- return;
- }
- const msg = e instanceof Error ? e.message : '쪽지를 불러올 수 없습니다.';
- setError(msg);
- }).finally(() => {
- if (!cancelled) {
- setLoading(false);
- }
- });
- return () => {
- cancelled = true;
- };
- }, [noteId]);
- // 본인이 sender 인 경우 보낸쪽지 모드 — 답장 버튼 숨기고 상대방으로 receiver 표시
- const isSenderView = note !== null && member !== null && note.senderMemberID === member.id;
- const counterpartyThumb = isSenderView ? note?.receiverThumb : note?.senderThumb;
- const counterpartyLabel = isSenderView ? '받는 사람' : '보낸 사람';
- const counterparty = note ? formatNoteCounterparty(isSenderView ? {
- name: note.receiverName,
- sid: note.receiverSID,
- channelName: note.receiverChannelName,
- channelHandle: note.receiverChannelHandle
- } : {
- isSystem: note.isSystem,
- name: note.senderName,
- sid: note.senderSID,
- channelName: note.senderChannelName,
- channelHandle: note.senderChannelHandle
- }) : { primary: '', secondary: null };
- const counterpartyName = counterparty.primary;
- const handleReply = () => {
- if (!note || note.isSystem || !note.senderMemberID) {
- return;
- }
- const sender: RecipientItem = {
- memberID: note.senderMemberID,
- displayName: note.senderName ?? '회원',
- displaySubtitle: null,
- thumbnailUrl: note.senderThumb
- };
- onReply?.(sender, note.title);
- };
- const handleDelete = async () => {
- if (!note) {
- return;
- }
- if (!confirm('이 쪽지를 삭제하시겠습니까?')) {
- return;
- }
- setDeleting(true);
- try {
- const res = await fetchApi(`/api/note/${note.id}`, { method: 'DELETE' });
- if (res.success) {
- onDeleted?.();
- onClose();
- return;
- }
- setError(res.message || '삭제에 실패했습니다.');
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : '삭제에 실패했습니다.';
- setError(msg);
- } finally {
- setDeleting(false);
- }
- };
- const open = noteId !== null;
- const canReply = !isSenderView && note !== null && !note.isSystem && note.senderMemberID !== null;
- return (
- <Dialog open={open} onOpenChange={(o) => { if (!o) { onClose(); } }}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle>{note?.title ?? '쪽지'}</DialogTitle>
- </DialogHeader>
- <div className="mt-2">
- {loading && <Loading type={2} />}
- {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>
- )}
- {note && !loading && (
- <>
- <div className="flex items-center gap-2 mb-3">
- {counterpartyThumb ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={counterpartyThumb} alt={counterpartyName} 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">
- {(counterpartyName ?? '?')[0]?.toUpperCase()}
- </div>
- )}
- <div className="flex-1">
- <div className="text-sm font-medium">
- <span className="text-xs text-gray-500 mr-1">{counterpartyLabel}:</span>
- {counterpartyName}
- {counterparty.secondary && (
- <span className="ml-1 text-xs text-gray-500">{counterparty.secondary}</span>
- )}
- {note.isSystem && (
- <span className="ml-2 text-xs bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">시스템</span>
- )}
- {isSenderView && (
- <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'}`}>
- {note.isRead ? '읽음' : '안읽음'}
- </span>
- )}
- </div>
- <div className="text-xs text-gray-500">{formatDate(note.createdAt)}</div>
- </div>
- </div>
- <div className="text-sm whitespace-pre-wrap mb-4 max-h-72 overflow-y-auto">
- {note.content}
- </div>
- <div className="flex gap-2">
- {canReply && (
- <button
- type="button"
- onClick={handleReply}
- disabled={deleting}
- 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"
- >
- <Reply size={14} /> 답장
- </button>
- )}
- <button
- type="button"
- onClick={handleDelete}
- disabled={deleting}
- 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"
- >
- <Trash2 size={14} /> {deleting ? '삭제 중…' : '삭제'}
- </button>
- </div>
- </>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- }
|