- 메시지 타입 정의 + 목 데이터 10건 (types.ts) - iOS/Android 푸시 알림 프리뷰 컴포넌트 (MessagePreview) - 슬라이드 패널 상세 보기 + 삭제 기능 (MessageSlidePanel) - 메시지 목록 페이지: 필터(ID/제목 검색, 서비스), 테이블, 페이지네이션 - 메시지 작성 페이지: 12-col 그리드 폼 + 실시간 프리뷰, 필수 검증, 저장 모달 Closes #19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
9.7 KiB
TypeScript
263 lines
9.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import CopyButton from "@/components/common/CopyButton";
|
|
import MessagePreview from "./MessagePreview";
|
|
import { MOCK_MESSAGE_DETAILS } from "../types";
|
|
|
|
interface MessageSlidePanelProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
messageId: string | null;
|
|
}
|
|
|
|
export default function MessageSlidePanel({
|
|
isOpen,
|
|
onClose,
|
|
messageId,
|
|
}: MessageSlidePanelProps) {
|
|
const detail = messageId ? MOCK_MESSAGE_DETAILS[messageId] : null;
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 패널 열릴 때 스크롤 최상단으로 리셋
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
bodyRef.current?.scrollTo(0, 0);
|
|
}
|
|
}, [isOpen, messageId]);
|
|
|
|
// ESC 닫기
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && isOpen) onClose();
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
// 패널 열릴 때 body 스크롤 잠금
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.body.style.overflow = "hidden";
|
|
} else {
|
|
document.body.style.overflow = "";
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [isOpen]);
|
|
|
|
return (
|
|
<>
|
|
{/* 오버레이 */}
|
|
<div
|
|
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
}`}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* 패널 */}
|
|
<aside
|
|
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
|
isOpen ? "translate-x-0" : "translate-x-full"
|
|
}`}
|
|
>
|
|
{/* 패널 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200">
|
|
<h2 className="text-lg font-bold text-[#0f172a]">메시지 상세</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
|
>
|
|
<span className="material-symbols-outlined text-xl">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 패널 본문 */}
|
|
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6">
|
|
{detail ? (
|
|
<div className="space-y-5">
|
|
{/* 메시지 ID */}
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider flex-shrink-0">
|
|
메시지 ID
|
|
</label>
|
|
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
|
|
{detail.messageId}
|
|
</code>
|
|
<CopyButton text={detail.messageId} />
|
|
</div>
|
|
|
|
<div className="h-px bg-gray-100" />
|
|
|
|
{/* 서비스 선택 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
|
서비스 선택
|
|
</label>
|
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
|
|
{detail.serviceName}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 제목 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
|
제목
|
|
</label>
|
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
|
|
{detail.title}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 내용 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
|
내용
|
|
</label>
|
|
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[120px]">
|
|
{detail.body}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 이미지 URL */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
|
이미지 URL
|
|
</label>
|
|
<p
|
|
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${
|
|
detail.imageUrl
|
|
? "text-gray-500"
|
|
: "italic text-gray-400"
|
|
}`}
|
|
>
|
|
{detail.imageUrl || "등록된 이미지 없음"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 링크 URL */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<label className="text-sm font-medium text-[#0f172a]">
|
|
링크 URL
|
|
</label>
|
|
<span
|
|
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
|
|
detail.linkType === "deeplink"
|
|
? "bg-purple-100 text-purple-700"
|
|
: "bg-blue-100 text-blue-700"
|
|
}`}
|
|
>
|
|
{detail.linkType === "deeplink" ? "딥링크" : "웹 링크"}
|
|
</span>
|
|
</div>
|
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 truncate">
|
|
{detail.linkUrl || "—"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 기타 정보 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
|
기타 정보
|
|
</label>
|
|
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[80px] whitespace-pre-line">
|
|
{detail.extra || "—"}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-px bg-gray-100" />
|
|
|
|
{/* 프리뷰 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-3">
|
|
프리뷰
|
|
</label>
|
|
<MessagePreview
|
|
title={detail.title}
|
|
body={detail.body}
|
|
hasImage={!!detail.imageUrl}
|
|
appName={detail.serviceName}
|
|
variant="small"
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-gray-100" />
|
|
|
|
{/* 삭제 버튼 */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="w-full flex items-center justify-center border border-red-300 text-red-600 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition"
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
|
메시지를 선택해주세요
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
{showDeleteConfirm && detail && (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/40"
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
/>
|
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
|
<span className="material-symbols-outlined text-red-600 text-xl">
|
|
warning
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg font-bold text-[#0f172a]">메시지 삭제</h3>
|
|
</div>
|
|
<p className="text-sm text-[#0f172a] mb-2">
|
|
<strong>{detail.messageId}</strong> 메시지를 삭제하시겠습니까?
|
|
</p>
|
|
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5">
|
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
|
<span
|
|
className="material-symbols-outlined flex-shrink-0"
|
|
style={{ fontSize: "14px" }}
|
|
>
|
|
info
|
|
</span>
|
|
<span>삭제 후 복구가 불가능합니다.</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
toast.success(`${detail.messageId} 메시지가 삭제되었습니다.`);
|
|
setShowDeleteConfirm(false);
|
|
onClose();
|
|
}}
|
|
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
|
|
>
|
|
<span className="material-symbols-outlined text-base">
|
|
delete
|
|
</span>
|
|
<span>삭제</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|