chore: add deployment config
This commit is contained in:
101
src/components/comment/CommentForm.tsx
Normal file
101
src/components/comment/CommentForm.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createComment } from '@/api/comments';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CommentFormProps {
|
||||
postSlug: string;
|
||||
parentId?: number | null;
|
||||
onSuccess?: () => void; // 작성 완료 후 콜백 (답글창 닫기 등)
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
|
||||
const { isLoggedIn, role } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [guestInfo, setGuestInfo] = useState({ nickname: '', password: '' });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createComment,
|
||||
onSuccess: () => {
|
||||
setContent('');
|
||||
setGuestInfo({ nickname: '', password: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (!guestInfo.nickname.trim() || !guestInfo.password.trim()) {
|
||||
alert('닉네임과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
postSlug,
|
||||
content,
|
||||
parentId,
|
||||
guestNickname: isLoggedIn ? undefined : guestInfo.nickname,
|
||||
guestPassword: isLoggedIn ? undefined : guestInfo.password,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||
{/* 비회원 입력 필드 */}
|
||||
{!isLoggedIn && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="닉네임"
|
||||
value={guestInfo.nickname}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })}
|
||||
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={guestInfo.password}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })}
|
||||
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 댓글 내용 입력 */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'}
|
||||
className="w-full p-3 pr-12 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 resize-none h-24 bg-white"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="absolute bottom-3 right-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300"
|
||||
title="등록"
|
||||
>
|
||||
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
207
src/components/comment/CommentItem.tsx
Normal file
207
src/components/comment/CommentItem.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Comment } from '@/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { deleteComment, deleteAdminComment } from '@/api/comments';
|
||||
import { format } from 'date-fns';
|
||||
import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, ShieldAlert, X } from 'lucide-react'; // X 아이콘 추가
|
||||
import CommentForm from './CommentForm';
|
||||
import { clsx } from 'clsx';
|
||||
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
postSlug: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) {
|
||||
const { isLoggedIn, role, user } = useAuthStore();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
// 🎨 비회원 삭제용 UI 상태 추가
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [guestPassword, setGuestPassword] = useState('');
|
||||
|
||||
const isAdmin = isLoggedIn && role?.includes('ADMIN');
|
||||
const isGuestComment = !comment.memberId;
|
||||
|
||||
const isMyComment = isLoggedIn && !isGuestComment && (
|
||||
(user?.memberId && comment.memberId && user.memberId === comment.memberId) ||
|
||||
(user?.nickname === comment.author)
|
||||
);
|
||||
|
||||
const showDeleteButton = isAdmin || isGuestComment || isMyComment;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
toast.success('댓글이 삭제되었습니다.');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.'));
|
||||
},
|
||||
});
|
||||
|
||||
const adminDeleteMutation = useMutation({
|
||||
mutationFn: deleteAdminComment,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
toast.success('관리자 권한으로 삭제했습니다.');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message));
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
// A. 관리자 -> 즉시 삭제 (컨펌만)
|
||||
if (isAdmin) {
|
||||
if (confirm('관리자 권한으로 삭제하시겠습니까?')) {
|
||||
adminDeleteMutation.mutate(comment.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// B. 내 댓글 -> 즉시 삭제 (컨펌만)
|
||||
if (isMyComment) {
|
||||
if (confirm('이 댓글을 삭제하시겠습니까?')) {
|
||||
deleteMutation.mutate({ id: comment.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// C. 비회원 댓글 -> 인라인 입력창 표시 (UX 개선)
|
||||
if (isGuestComment) {
|
||||
setIsDeleting(!isDeleting);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuestDeleteSubmit = () => {
|
||||
if (!guestPassword.trim()) {
|
||||
toast.error('비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ id: comment.id, password: guestPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col", depth > 0 && "mt-3")}>
|
||||
<div
|
||||
className={clsx(
|
||||
"relative p-4 rounded-xl transition-colors group",
|
||||
comment.isPostAuthor ? "bg-blue-50/50 border border-blue-100" : "bg-white border border-gray-100"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={clsx(
|
||||
"p-1.5 rounded-full flex items-center justify-center",
|
||||
comment.isPostAuthor ? "bg-blue-100 text-blue-600" :
|
||||
!isGuestComment ? "bg-green-100 text-green-600" :
|
||||
"bg-gray-100 text-gray-500"
|
||||
)}
|
||||
>
|
||||
{comment.isPostAuthor ? <CheckCircle2 size={14} /> :
|
||||
!isGuestComment ? <UserCheck size={14} /> :
|
||||
<User size={14} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
|
||||
<span className={clsx("text-sm font-bold flex items-center gap-1", comment.isPostAuthor ? "text-blue-700" : "text-gray-700")}>
|
||||
{comment.author}
|
||||
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium">작성자</span>}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setIsReplying(!isReplying)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="답글 달기"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
</button>
|
||||
|
||||
{showDeleteButton && (
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className={clsx(
|
||||
"p-1.5 rounded transition-colors",
|
||||
isDeleting ? "bg-red-50 text-red-600" :
|
||||
isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50"
|
||||
)}
|
||||
title={isAdmin ? "관리자 삭제" : "삭제"}
|
||||
>
|
||||
{isAdmin ? <ShieldAlert size={14} /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-800 text-sm whitespace-pre-wrap leading-relaxed pl-1">
|
||||
{comment.content}
|
||||
</p>
|
||||
|
||||
{/* 🎨 비회원 비밀번호 입력창 (인라인) */}
|
||||
{isDeleting && isGuestComment && (
|
||||
<div className="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="비밀번호 입력"
|
||||
value={guestPassword}
|
||||
onChange={(e) => setGuestPassword(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 border border-gray-200 rounded focus:outline-none focus:border-blue-500 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleGuestDeleteSubmit}
|
||||
className="text-xs bg-red-500 text-white px-3 py-1.5 rounded hover:bg-red-600 transition-colors font-medium"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsDeleting(false); setGuestPassword(''); }}
|
||||
className="p-1 text-gray-400 hover:bg-gray-200 rounded-full"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReplying && (
|
||||
<div className="mt-2 pl-4 border-l-2 border-gray-200 ml-4 animate-in fade-in slide-in-from-top-2">
|
||||
<CommentForm
|
||||
postSlug={postSlug}
|
||||
parentId={comment.id}
|
||||
onSuccess={() => setIsReplying(false)}
|
||||
placeholder={`@${comment.author}님에게 답글 남기기`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comment.children && comment.children.length > 0 && (
|
||||
<div className="mt-2 pl-4 md:pl-8 border-l-2 border-gray-100 ml-2 md:ml-4 space-y-3">
|
||||
{comment.children.map((child) => (
|
||||
<CommentItem
|
||||
key={child.id}
|
||||
comment={child}
|
||||
postSlug={postSlug}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/comment/CommentList.tsx
Normal file
64
src/components/comment/CommentList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getComments } from '@/api/comments';
|
||||
import CommentForm from './CommentForm';
|
||||
import CommentItem from './CommentItem';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
|
||||
interface CommentListProps {
|
||||
postSlug: string;
|
||||
}
|
||||
|
||||
export default function CommentList({ postSlug }: CommentListProps) {
|
||||
// 댓글 목록 조회
|
||||
const { data: comments, isLoading, error } = useQuery({
|
||||
queryKey: ['comments', postSlug],
|
||||
queryFn: () => getComments(postSlug),
|
||||
});
|
||||
|
||||
// 총 댓글 수 계산 (재귀)
|
||||
const countComments = (list: any[]): number => {
|
||||
if (!list) return 0;
|
||||
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||
};
|
||||
|
||||
const totalCount = comments ? countComments(comments) : 0;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="py-10 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="py-10 text-center text-red-500">댓글을 불러오는데 실패했습니다.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-16 pt-10 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<MessageCircle className="text-blue-600" size={24} />
|
||||
<h3 className="text-xl font-bold text-gray-800">
|
||||
댓글 <span className="text-blue-600">{totalCount}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 최상위 댓글 작성 폼 */}
|
||||
<div className="mb-10">
|
||||
<CommentForm postSlug={postSlug} />
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
<div className="space-y-6">
|
||||
{comments && comments.length > 0 ? (
|
||||
comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} postSlug={postSlug} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-500 text-sm">
|
||||
아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +1,444 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen } from 'lucide-react';
|
||||
// 🎨 이미지 최적화를 위해 next/image 사용
|
||||
import Image from 'next/image';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/category';
|
||||
import { getProfile, updateProfile } from '@/api/profile';
|
||||
import { uploadImage } from '@/api/image';
|
||||
import {
|
||||
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
|
||||
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||
|
||||
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
||||
for (const cat of categories) {
|
||||
if (cat.id === id) return cat.name;
|
||||
if (cat.children && cat.children.length > 0) {
|
||||
const found = findCategoryNameById(cat.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: Category;
|
||||
depth: number;
|
||||
pathname: string;
|
||||
isEditMode: boolean;
|
||||
onDrop: (draggedId: number, targetId: number | null) => void;
|
||||
onAdd: (parentId: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
||||
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('categoryId', category.id.toString());
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isEditMode) {
|
||||
setIsDragOver(true);
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (!isEditMode) return;
|
||||
|
||||
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
||||
if (!draggedId || draggedId === category.id) return;
|
||||
|
||||
if (confirm(`'${category.name}' 하위로 이동하시겠습니까?`)) {
|
||||
onDrop(draggedId, category.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-4 py-2 text-sm rounded-lg transition-all group relative',
|
||||
isActive && !isEditMode ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50',
|
||||
isEditMode && 'border border-dashed border-gray-300 hover:border-blue-400 cursor-move bg-white',
|
||||
isDragOver && 'bg-blue-100 border-blue-500'
|
||||
)}
|
||||
style={{ marginLeft: `${depth * 12}px` }}
|
||||
draggable={isEditMode}
|
||||
onDragStart={isEditMode ? handleDragStart : undefined}
|
||||
onDragOver={isEditMode ? handleDragOver : undefined}
|
||||
onDragLeave={isEditMode ? handleDragLeave : undefined}
|
||||
onDrop={isEditMode ? handleDrop : undefined}
|
||||
>
|
||||
{!isEditMode ? (
|
||||
<Link href={`/category/${category.name}`} className="flex-1 flex items-center gap-2.5">
|
||||
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span>{category.name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2.5">
|
||||
<Move size={14} className="text-gray-400" />
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAdd(category.id); }}
|
||||
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||
title="하위 카테고리 추가"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(category.id); }}
|
||||
className="p-1 text-red-500 hover:bg-red-100 rounded"
|
||||
title="카테고리 삭제"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditMode && category.children && category.children.length > 0 && (
|
||||
<ChevronRight size={14} className={clsx("text-gray-300 transition-transform", isActive && "rotate-90")} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{category.children && category.children.length > 0 && (
|
||||
<div className="border-l-2 border-gray-100 ml-4">
|
||||
{category.children.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={child}
|
||||
depth={0}
|
||||
pathname={pathname}
|
||||
isEditMode={isEditMode}
|
||||
onDrop={onDrop}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const [isOpen, setIsOpen] = useState(true); // 사이드바 열림/닫힘 상태
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const pathname = usePathname();
|
||||
const { role, _hasHydrated } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
|
||||
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
|
||||
});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isCategoryEditMode, setIsCategoryEditMode] = useState(false);
|
||||
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
||||
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
setIsCategoryEditMode(false);
|
||||
setIsEditModalOpen(false);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
// 1. 서버에서 카테고리 데이터 가져오기
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const createCategoryMutation = useMutation({
|
||||
mutationFn: createCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
toast.success('카테고리가 생성되었습니다.');
|
||||
},
|
||||
onError: (err: any) => toast.error('생성 실패: ' + (err.response?.data?.message || err.message)),
|
||||
});
|
||||
|
||||
const updateCategoryMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => updateCategory(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
|
||||
onError: (err: any) => toast.error('이동 실패: ' + (err.response?.data?.message || err.message)),
|
||||
});
|
||||
|
||||
const deleteCategoryMutation = useMutation({
|
||||
mutationFn: deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
toast.success('카테고리가 삭제되었습니다.');
|
||||
},
|
||||
onError: (err: any) => toast.error('삭제 실패: ' + (err.response?.data?.message || err.message)),
|
||||
});
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
setIsEditModalOpen(false);
|
||||
toast.success('프로필이 수정되었습니다!');
|
||||
},
|
||||
onError: (error: any) => toast.error('수정 실패: ' + (error.response?.data?.message || error.message)),
|
||||
});
|
||||
|
||||
const handleAddCategory = (parentId: number | null) => {
|
||||
// 🎨 Prompt 대신 간단한 로직 유지 (복잡도 증가 방지)
|
||||
// 실제로는 모달로 바꾸는게 좋지만, 여기선 일단 Toast만 적용
|
||||
const name = prompt('새 카테고리 이름을 입력하세요:');
|
||||
if (!name || !name.trim()) return;
|
||||
createCategoryMutation.mutate({ name, parentId });
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (id: number) => {
|
||||
if (confirm('정말로 이 카테고리를 삭제하시겠습니까?\n하위 카테고리와 게시글이 모두 삭제될 수 있습니다.')) {
|
||||
deleteCategoryMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCategory = useCallback((draggedId: number, targetParentId: number | null) => {
|
||||
if (draggedId === targetParentId) return;
|
||||
if (!categories) return;
|
||||
|
||||
const currentName = findCategoryNameById(categories, draggedId);
|
||||
if (!currentName) {
|
||||
toast.error('카테고리 정보를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateCategoryMutation.mutate({
|
||||
id: draggedId,
|
||||
data: { name: currentName, parentId: targetParentId }
|
||||
});
|
||||
}, [categories, updateCategoryMutation]);
|
||||
|
||||
const handleRootDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsRootDragOver(false);
|
||||
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
||||
if (!draggedId) return;
|
||||
|
||||
if (confirm('이 카테고리를 최상위로 이동하시겠습니까?')) {
|
||||
handleMoveCategory(draggedId, null);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultProfile: Profile = {
|
||||
name: 'Dev Park',
|
||||
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
||||
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
||||
githubUrl: 'https://github.com',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
|
||||
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditForm({
|
||||
name: displayProfile.name,
|
||||
bio: displayProfile.bio,
|
||||
imageUrl: displayProfile.imageUrl,
|
||||
githubUrl: displayProfile.githubUrl || '',
|
||||
email: displayProfile.email || '',
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
setEditForm((prev) => ({ ...prev, imageUrl: res.data }));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('이미지 업로드 오류');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateProfileMutation.mutate(editForm);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 📱 모바일용 메뉴 토글 버튼 (화면 왼쪽 위에 고정) */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors">
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 🖥️ 사이드바 본체 */}
|
||||
<aside
|
||||
className={clsx(
|
||||
// 기본 스타일 & 애니메이션
|
||||
'fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out overflow-y-auto scrollbar-hide',
|
||||
// 열렸을 때 vs 닫혔을 때 너비 조절
|
||||
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
{/* A. 프로필 영역 */}
|
||||
<div className={clsx('p-6 text-center transition-opacity duration-200', !isOpen && 'md:opacity-0 md:hidden')}>
|
||||
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50">
|
||||
{/* 프로필 이미지 (임시) */}
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/notionists/svg?seed=Felix"
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-800">Dev Park</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">풀스택을 꿈꾸는 개발자</p>
|
||||
<p className="text-xs text-gray-400 mt-3 font-light leading-relaxed">
|
||||
"코드로 세상을 바꾸고 싶은<br />박개발의 기술 블로그입니다."
|
||||
</p>
|
||||
<aside className={clsx('fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out overflow-y-auto scrollbar-hide', isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0', 'flex flex-col')}>
|
||||
<div className={clsx('p-6 text-center transition-opacity duration-200 relative group', !isOpen && 'md:opacity-0 md:hidden')}>
|
||||
{isAdmin && (
|
||||
<button onClick={handleEditClick} className="absolute top-4 right-4 p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-all opacity-0 group-hover:opacity-100 z-10" title="프로필 수정">
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
||||
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
|
||||
{/* 🛠️ 수정됨: 로딩 중일 때는 이미지 대신 스켈레톤 표시 */}
|
||||
{isProfileLoading ? (
|
||||
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||
) : (
|
||||
<Image
|
||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||
alt="Profile"
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isProfileLoading ? (
|
||||
<div className="space-y-2 flex flex-col items-center"><div className="h-6 w-24 bg-gray-200 rounded animate-pulse" /><div className="h-4 w-32 bg-gray-100 rounded animate-pulse" /></div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1 whitespace-pre-line leading-relaxed">{displayProfile.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* B. 네비게이션 & 카테고리 */}
|
||||
<nav className="flex-1 px-4 py-2">
|
||||
{/* 닫혔을 때(좁은 모드) 메뉴 아이콘 표시 */}
|
||||
<div className={clsx('flex flex-col items-center gap-4 mt-4', isOpen && 'hidden')}>
|
||||
<Folder size={24} className="text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* 열렸을 때 메뉴 목록 */}
|
||||
<div className={clsx('space-y-1', !isOpen && 'md:hidden')}>
|
||||
<p className="px-4 text-xs font-bold text-gray-400 uppercase tracking-wider mb-3 mt-4">
|
||||
Categories
|
||||
</p>
|
||||
|
||||
{/* 로딩 중일 때 스켈레톤 UI */}
|
||||
{!categories && (
|
||||
<div className="space-y-2 px-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 카테고리 렌더링 */}
|
||||
{categories?.map((cat) => {
|
||||
// 현재 카테고리(또는 자식)가 선택되었는지 확인
|
||||
const isActive = pathname.includes(`/category/${cat.id}`);
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="mb-1">
|
||||
{/* 1차 카테고리 */}
|
||||
<Link
|
||||
href={`/category/${cat.id}`}
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-4 py-2.5 text-sm font-medium rounded-lg transition-all group',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
{cat.children && cat.children.length > 0 && (
|
||||
<ChevronRight size={14} className={clsx("text-gray-300 transition-transform", isActive && "rotate-90")} />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* 2차 카테고리 (자식이 있을 경우) */}
|
||||
{cat.children && cat.children.length > 0 && (
|
||||
<div className="ml-5 mt-1 space-y-0.5 border-l-2 border-gray-100 pl-2">
|
||||
{cat.children.map((child) => {
|
||||
const isChildActive = pathname.includes(`/category/${child.id}`);
|
||||
return (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/category/${child.id}`}
|
||||
className={clsx(
|
||||
"block px-3 py-2 text-sm rounded-md transition-colors",
|
||||
isChildActive
|
||||
? "text-blue-600 font-medium bg-blue-50/50"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
- {child.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<nav className="flex-1 px-4 py-2 flex flex-col">
|
||||
<div className={clsx('flex flex-col items-center gap-4 mt-4', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
||||
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
|
||||
<div className="flex items-center justify-between px-4 mb-3 mt-4 h-8">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
{!isCategoryEditMode ? (
|
||||
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleAddCategory(null)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="최상위 카테고리 추가"><Plus size={16} /></button>
|
||||
<button onClick={() => setIsCategoryEditMode(false)} className="p-1 text-gray-500 hover:bg-gray-100 rounded" title="관리 종료"><X size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>}
|
||||
|
||||
<div
|
||||
className={clsx("min-h-[100px] pb-10 transition-colors rounded-lg", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")}
|
||||
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
|
||||
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
||||
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
||||
>
|
||||
{categories?.map((cat) => (
|
||||
<CategoryItem key={cat.id} category={cat} depth={0} pathname={pathname} isEditMode={isCategoryEditMode} onDrop={handleMoveCategory} onAdd={handleAddCategory} onDelete={handleDeleteCategory} />
|
||||
))}
|
||||
|
||||
{!isCategoryEditMode && (
|
||||
<div className="mb-1 mt-2">
|
||||
<Link
|
||||
href="/category/uncategorized"
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all',
|
||||
pathname === '/category/uncategorized'
|
||||
? 'bg-blue-50 text-blue-600 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<FileQuestion size={16} />
|
||||
<span>미분류</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* C. 광고 영역 (거슬리지 않게 하단 배치) */}
|
||||
<div className={clsx('px-6 pb-6', !isOpen && 'hidden')}>
|
||||
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl border border-dashed border-gray-200 text-center relative overflow-hidden group cursor-pointer hover:border-blue-200 transition-colors">
|
||||
<div className="absolute top-0 right-0 p-1">
|
||||
<span className="text-[9px] bg-gray-200 text-gray-500 px-1 rounded">AD</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-500 font-semibold mb-1">AWS Cloud School</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
국비지원 과정 모집중<br/>
|
||||
<span className="underline group-hover:text-blue-600">자세히 보기 →</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* D. 소셜 링크 (최하단) */}
|
||||
<div className={clsx('p-6 border-t border-gray-100 bg-white', !isOpen && 'hidden')}>
|
||||
<div className="flex justify-center gap-3">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm hover:-translate-y-1"
|
||||
aria-label="Github"
|
||||
>
|
||||
<Github size={18} />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:user@example.com"
|
||||
className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm hover:-translate-y-1"
|
||||
aria-label="Email"
|
||||
>
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
<a href={displayProfile.githubUrl || '#'} target="_blank" rel="noreferrer" className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm"><Github size={18} /></a>
|
||||
<a href={`mailto:${displayProfile.email}`} className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm"><Mail size={18} /></a>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">
|
||||
© 2024 Dev Park. All rights reserved.
|
||||
</p>
|
||||
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">© 2024 {displayProfile.name}. All rights reserved.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2"><Edit3 size={18} className="text-blue-600" /> 프로필 수정</h3>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="text-gray-400 hover:text-gray-600"><XCircle size={24} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="relative w-24 h-24 mb-3 group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
|
||||
{/* 🎨 모달 내부 프리뷰 이미지에도 적용 */}
|
||||
<Image src={editForm.imageUrl || defaultProfile.imageUrl!} alt="Preview" fill className="rounded-full object-cover border-2 border-gray-100 group-hover:border-blue-300 transition-colors" unoptimized />
|
||||
<div className="absolute inset-0 bg-black/30 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"><Camera className="text-white" size={24} /></div>
|
||||
</div>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImageUpload} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-600 font-medium hover:underline" disabled={isUploading}>{isUploading ? '업로드 중...' : '이미지 변경'}</button>
|
||||
</div>
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">이름 (Name)</label><input type="text" value={editForm.name} onChange={(e) => setEditForm({...editForm, name: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" required /></div>
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">소개 (Bio)</label><textarea value={editForm.bio} onChange={(e) => setEditForm({...editForm, bio: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm resize-none h-24" required /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">Github URL</label><input type="url" value={editForm.githubUrl || ''} onChange={(e) => setEditForm({...editForm, githubUrl: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">Email</label><input type="email" value={editForm.email || ''} onChange={(e) => setEditForm({...editForm, email: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
||||
</div>
|
||||
<div className="pt-4 flex gap-2">
|
||||
<button type="button" onClick={() => setIsEditModalOpen(false)} className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors">취소</button>
|
||||
<button type="submit" disabled={updateProfileMutation.isPending || isUploading} className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-sm disabled:bg-gray-400 flex justify-center items-center gap-2">{updateProfileMutation.isPending ? '저장 중...' : <><Save size={16} /> 저장하기</>}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
// src/components/post/MarkdownRenderer.tsx
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
// 🛠️ 보안 패치: rehype-sanitize 추가 (반드시 npm install rehype-sanitize 실행 필요)
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Copy, Check, Terminal, ExternalLink } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
@@ -7,8 +16,187 @@ interface MarkdownRendererProps {
|
||||
|
||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className="prose prose-slate max-w-none prose-headings:font-bold prose-a:text-blue-600 hover:prose-a:text-blue-500 prose-img:rounded-xl">
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
// 🛡️ 중요: 여기서 HTML 태그를 소독하여 XSS 공격 방지
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
// 1. 코드 블록 커스텀 (Mac 스타일 윈도우 + 문법 강조)
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-red-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구 (Blockquote) 스타일
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-blue-500 bg-blue-50 pl-4 py-3 my-6 text-gray-700 rounded-r-lg italic shadow-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크 (a) 스타일
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium underline-offset-4 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블 스타일
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm">
|
||||
<table className="w-full text-sm text-left text-gray-700 bg-white">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3 font-bold text-gray-900">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지 스타일
|
||||
img({ src, alt }) {
|
||||
// rehype-sanitize가 적용되면 기본적으로 img 태그가 허용되지만,
|
||||
// onError 핸들링 등을 위해 커스텀 컴포넌트 유지는 좋음.
|
||||
return (
|
||||
<span className="block my-8">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="rounded-xl shadow-lg border border-gray-100 w-full object-cover max-h-[600px] hover:scale-[1.01] transition-transform duration-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 숨김 처리 혹은 플레이스홀더
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{alt && <span className="block text-center text-sm text-gray-400 mt-2">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일
|
||||
h1({ children }) {
|
||||
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1">{children}</h3>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 코드 블록 컴포넌트 (변경 없음)
|
||||
function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[#2d2d2d] border-b border-gray-700 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56] border border-[#E0443E]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFBD2E] border border-[#DEA123]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27C93F] border border-[#1AAB29]" />
|
||||
</div>
|
||||
{language && (
|
||||
<div className="ml-4 flex items-center gap-1.5 px-2 py-0.5 rounded text-[10px] font-mono font-medium text-gray-400 bg-gray-700/50 border border-gray-600/50">
|
||||
<Terminal size={10} />
|
||||
<span className="uppercase tracking-wider">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-all duration-200 border",
|
||||
isCopied
|
||||
? "bg-green-500/10 text-green-400 border-green-500/20"
|
||||
: "bg-gray-700/50 text-gray-400 border-transparent hover:bg-gray-600 hover:text-white"
|
||||
)}
|
||||
title="코드 복사"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative font-mono text-[14px] leading-relaxed">
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
showLineNumbers={true}
|
||||
lineNumberStyle={{ minWidth: '2.5em', paddingRight: '1em', color: '#6e7681', textAlign: 'right' }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1.5rem',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,9 +43,6 @@ export default function PostCard({ post }: { post: Post }) {
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>By Dev Park</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-blue-500 flex items-center gap-1">
|
||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</span>
|
||||
|
||||
41
src/components/post/PostListItem.tsx
Normal file
41
src/components/post/PostListItem.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import { format } from 'date-fns';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post }: PostListItemProps) {
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group">
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-100 hover:bg-gray-50 px-4 -mx-4 rounded-lg transition-colors">
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* 카테고리 라벨 (작은 화면에선 숨김 처리 가능) */}
|
||||
<span className="hidden sm:inline-block px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 text-xs font-medium whitespace-nowrap">
|
||||
{post.categoryName}
|
||||
</span>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="text-base font-medium text-gray-800 truncate group-hover:text-blue-600 transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap">
|
||||
{/* 조회수 */}
|
||||
<div className="hidden sm:flex items-center gap-1.5" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">{post.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* 날짜 */}
|
||||
<time className="font-light tabular-nums text-xs sm:text-sm">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user