- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
233 lines
7.7 KiB
TypeScript
233 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 게시글 상세 보기 컴포넌트
|
|
*
|
|
* 디자인 스펙 기준:
|
|
* - 페이지 타이틀: 게시글 상세
|
|
* - 페이지 설명: 게시글을 조회합니다.
|
|
* - 삭제/수정 버튼 (본인 글만 표시)
|
|
* - 게시판명 라벨
|
|
* - 제목
|
|
* - 메타 정보 (작성자 | 날짜 | 조회수)
|
|
* - 내용 (HTML 렌더링)
|
|
* - 첨부파일 다운로드 링크
|
|
* - 댓글 섹션 (댓글 사용함 설정 시만 표시)
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { format } from 'date-fns';
|
|
import { FileText, Download, ArrowLeft } from 'lucide-react';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
} from '@/components/ui/card';
|
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import { toast } from 'sonner';
|
|
import { CommentSection } from '../CommentSection';
|
|
import { deletePost } from '../actions';
|
|
import type { Post, Comment } from '../types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
interface BoardDetailProps {
|
|
post: Post;
|
|
comments: Comment[];
|
|
currentUserId: string;
|
|
}
|
|
|
|
export function BoardDetail({ post, comments: initialComments, currentUserId }: BoardDetailProps) {
|
|
const router = useRouter();
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [comments, setComments] = useState<Comment[]>(initialComments);
|
|
|
|
const isMyPost = post.authorId === currentUserId;
|
|
|
|
// ===== 액션 핸들러 =====
|
|
const handleBack = useCallback(() => {
|
|
router.push('/ko/board');
|
|
}, [router]);
|
|
|
|
const handleEdit = useCallback(() => {
|
|
router.push(`/ko/board/${post.boardCode}/${post.id}?mode=edit`);
|
|
}, [router, post.boardCode, post.id]);
|
|
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
setIsDeleting(true);
|
|
try {
|
|
const result = await deletePost(post.boardCode, post.id);
|
|
if (result.success) {
|
|
toast.success('게시글이 삭제되었습니다.');
|
|
router.push('/ko/board');
|
|
} else {
|
|
toast.error(result.error || '게시글 삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('게시글 삭제 오류:', error);
|
|
toast.error('게시글 삭제에 실패했습니다.');
|
|
} finally {
|
|
setIsDeleting(false);
|
|
setShowDeleteDialog(false);
|
|
}
|
|
}, [post.boardCode, post.id, router]);
|
|
|
|
// ===== 댓글 핸들러 =====
|
|
// TODO: 댓글 API 연동 (별도 작업)
|
|
const handleAddComment = useCallback((content: string) => {
|
|
const newComment: Comment = {
|
|
id: `comment-${Date.now()}`,
|
|
postId: post.id,
|
|
authorId: currentUserId,
|
|
authorName: '사용자',
|
|
content,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
setComments((prev) => [...prev, newComment]);
|
|
}, [post.id, currentUserId]);
|
|
|
|
const handleUpdateComment = useCallback((id: string, content: string) => {
|
|
setComments((prev) =>
|
|
prev.map((c) =>
|
|
c.id === id ? { ...c, content, updatedAt: new Date().toISOString() } : c
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handleDeleteComment = useCallback((id: string) => {
|
|
setComments((prev) => prev.filter((c) => c.id !== id));
|
|
}, []);
|
|
|
|
// 파일 크기 포맷
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
return (
|
|
<PageLayout>
|
|
{/* 헤더 */}
|
|
<PageHeader
|
|
title="게시글 상세"
|
|
description="게시글을 조회합니다."
|
|
icon={FileText}
|
|
actions={
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={handleBack}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
목록
|
|
</Button>
|
|
{isMyPost && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
className="text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
>
|
|
삭제
|
|
</Button>
|
|
<Button onClick={handleEdit}>수정</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* 게시글 카드 */}
|
|
<Card className="mb-6">
|
|
<CardHeader className="pb-4">
|
|
{/* 게시판명 라벨 */}
|
|
<Badge variant="secondary" className="w-fit mb-2">
|
|
{post.boardName}
|
|
</Badge>
|
|
|
|
{/* 제목 */}
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
{post.isPinned && <span className="text-red-500 mr-2">[공지]</span>}
|
|
{post.isSecret && <span className="text-gray-500 mr-2">[비밀]</span>}
|
|
{post.title}
|
|
</h2>
|
|
|
|
{/* 메타 정보: 작성자 | 날짜 | 조회수 */}
|
|
<div className="flex items-center gap-2 text-sm text-gray-500 mt-2">
|
|
<span>{post.authorName}</span>
|
|
{post.authorDepartment && (
|
|
<>
|
|
<span className="text-gray-300">|</span>
|
|
<span>{post.authorDepartment}</span>
|
|
</>
|
|
)}
|
|
<span className="text-gray-300">|</span>
|
|
<span>{format(new Date(post.createdAt), 'yyyy-MM-dd HH:mm')}</span>
|
|
<span className="text-gray-300">|</span>
|
|
<span>조회수 {post.viewCount}</span>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-6">
|
|
{/* 내용 (HTML 렌더링) */}
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
|
/>
|
|
|
|
{/* 첨부파일 */}
|
|
{post.attachments.length > 0 && (
|
|
<div className="space-y-2 pt-4 border-t border-gray-100">
|
|
<p className="text-sm font-medium text-gray-700">첨부파일</p>
|
|
{post.attachments.map((file) => (
|
|
<a
|
|
key={file.id}
|
|
href={file.fileUrl}
|
|
download={file.fileName}
|
|
className="flex items-center gap-2 p-2 bg-gray-50 rounded-md hover:bg-gray-100 transition-colors"
|
|
>
|
|
<Download className="h-4 w-4 text-gray-500" />
|
|
<span className="text-sm text-blue-600 hover:underline">
|
|
{file.fileName}
|
|
</span>
|
|
<span className="text-xs text-gray-400">
|
|
({formatFileSize(file.fileSize)})
|
|
</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 댓글 섹션 (댓글 사용함 설정 시만 표시) */}
|
|
{post.allowComments && (
|
|
<CommentSection
|
|
postId={post.id}
|
|
comments={comments}
|
|
currentUserId={currentUserId}
|
|
onAddComment={handleAddComment}
|
|
onUpdateComment={handleUpdateComment}
|
|
onDeleteComment={handleDeleteComment}
|
|
/>
|
|
)}
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<DeleteConfirmDialog
|
|
open={showDeleteDialog}
|
|
onOpenChange={setShowDeleteDialog}
|
|
onConfirm={handleConfirmDelete}
|
|
title="게시글 삭제"
|
|
description="정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
|
loading={isDeleting}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
export default BoardDetail;
|