.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m52s

This commit is contained in:
ParkWonYeop
2025-12-28 23:26:37 +09:00
parent b262d796b8
commit b3ba5c2374

View File

@@ -7,7 +7,7 @@ import { createPost, updatePost, getPost } from '@/api/posts';
import { getCategories } from '@/api/category'; import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image'; import { uploadImage } from '@/api/image';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud } from 'lucide-react'; import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud, FileText, Trash2, X, Clock } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import axios from 'axios'; import axios from 'axios';
@@ -17,6 +17,14 @@ const MDEditor = dynamic(
{ ssr: false } { ssr: false }
); );
// 🛠️ 임시저장 데이터 타입 정의
interface DraftPost {
id: number;
title: string;
content: string;
savedAt: string;
}
// 🛠️ 1. 토큰 만료 체크 유틸리티 // 🛠️ 1. 토큰 만료 체크 유틸리티
function isTokenExpired(token: string) { function isTokenExpired(token: string) {
try { try {
@@ -51,6 +59,10 @@ function WritePageContent() {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// 💾 임시저장 관련 상태
const [drafts, setDrafts] = useState<DraftPost[]>([]);
const [showDraftList, setShowDraftList] = useState(false);
useEffect(() => { useEffect(() => {
if (_hasHydrated && (!role || !role.includes('ADMIN'))) { if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
toast.error('관리자 권한이 필요합니다.'); toast.error('관리자 권한이 필요합니다.');
@@ -58,6 +70,18 @@ function WritePageContent() {
} }
}, [role, _hasHydrated, router]); }, [role, _hasHydrated, router]);
// 컴포넌트 마운트 시 로컬스토리지에서 임시저장 목록 불러오기
useEffect(() => {
const savedDrafts = localStorage.getItem('temp_drafts');
if (savedDrafts) {
try {
setDrafts(JSON.parse(savedDrafts));
} catch (e) {
console.error('Failed to parse drafts', e);
}
}
}, []);
const { data: categories } = useQuery({ const { data: categories } = useQuery({
queryKey: ['categories'], queryKey: ['categories'],
queryFn: getCategories, queryFn: getCategories,
@@ -96,21 +120,59 @@ function WritePageContent() {
return null; return null;
}; };
// 💾 임시저장 기능
const handleTempSave = () => {
if (!title.trim() && !content.trim()) {
toast.error('제목이나 내용을 입력해주세요.');
return;
}
if (drafts.length >= 10) {
toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.');
setShowDraftList(true); // 목록 열어주기
return;
}
const newDraft: DraftPost = {
id: Date.now(),
title: title || '(제목 없음)',
content: content,
savedAt: new Date().toLocaleString(),
};
const newDrafts = [newDraft, ...drafts];
setDrafts(newDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
toast.success('임시저장 되었습니다!');
};
// 💾 임시저장 불러오기
const handleLoadDraft = (draft: DraftPost) => {
if (confirm('현재 작성 중인 내용이 사라집니다.\n선택한 임시저장 글을 불러오시겠습니까?')) {
setTitle(draft.title === '(제목 없음)' ? '' : draft.title);
setContent(draft.content);
setShowDraftList(false);
toast.success('불러오기 완료');
}
};
// 💾 임시저장 삭제
const handleDeleteDraft = (id: number) => {
const newDrafts = drafts.filter(d => d.id !== id);
setDrafts(newDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
toast.success('삭제되었습니다.');
};
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data), mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
onSuccess: async (response: any) => { onSuccess: async (response: any) => {
const savedPost = response?.data; const savedPost = response?.data;
const newSlug = savedPost?.slug || editSlug; const newSlug = savedPost?.slug || editSlug;
// 🚨 [핵심 수정] 목록 캐시를 '초기화(Reset)'합니다.
// 이렇게 하면 목록 페이지(Home, Category 등)로 이동했을 때
// 기존 캐시(옛날 글 목록)를 보여주지 않고, 즉시 서버에서 새 데이터를 가져옵니다.
await queryClient.resetQueries({ queryKey: ['posts'] }); await queryClient.resetQueries({ queryKey: ['posts'] });
// 카테고리 데이터(글 개수 등)도 갱신
await queryClient.invalidateQueries({ queryKey: ['categories'] }); await queryClient.invalidateQueries({ queryKey: ['categories'] });
// 상세 페이지 캐시 삭제 (다시 들어가면 새로 받도록)
if (editSlug) { if (editSlug) {
queryClient.removeQueries({ queryKey: ['post', editSlug] }); queryClient.removeQueries({ queryKey: ['post', editSlug] });
} }
@@ -119,7 +181,6 @@ function WritePageContent() {
} }
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!'); toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
router.push(isEditMode ? `/posts/${newSlug}` : '/'); router.push(isEditMode ? `/posts/${newSlug}` : '/');
}, },
onError: (err: any) => { onError: (err: any) => {
@@ -277,6 +338,7 @@ function WritePageContent() {
return ( return (
<div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}> <div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}>
{/* 상단 헤더 영역 */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors"> <button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors">
@@ -284,6 +346,83 @@ function WritePageContent() {
</button> </button>
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1> <h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
</div> </div>
<div className="flex items-center gap-3">
{/* 💾 임시저장 버튼 그룹 */}
<div className="relative">
<div className="flex items-center bg-white border border-gray-300 rounded-lg shadow-sm">
<button
onClick={handleTempSave}
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2 border-r border-gray-300 rounded-l-lg transition-colors"
title="현재 내용 임시저장"
>
<FileText size={16} className="text-gray-500" />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={() => setShowDraftList(!showDraftList)}
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-r-lg transition-colors flex items-center gap-1"
title="임시저장 목록 보기"
>
<span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded text-xs font-bold">
{drafts.length}
</span>
</button>
</div>
{/* 💾 임시저장 목록 팝업 */}
{showDraftList && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowDraftList(false)}
/>
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-20 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
<h3 className="font-bold text-gray-700 text-sm"> ({drafts.length}/10)</h3>
<button onClick={() => setShowDraftList(false)}><X size={16} className="text-gray-400 hover:text-gray-600" /></button>
</div>
<div className="max-h-80 overflow-y-auto">
{drafts.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm">
.
</div>
) : (
<ul className="divide-y divide-gray-100">
{drafts.map((draft) => (
<li key={draft.id} className="p-3 hover:bg-blue-50 transition-colors group">
<div className="flex justify-between items-start gap-2">
<button
onClick={() => handleLoadDraft(draft)}
className="flex-1 text-left"
>
<p className="font-medium text-gray-800 text-sm line-clamp-1 mb-1 group-hover:text-blue-600">
{draft.title}
</p>
<div className="flex items-center gap-1 text-xs text-gray-400">
<Clock size={12} />
{draft.savedAt}
</div>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteDraft(draft.id); }}
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
</div>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || isUploading} disabled={isSubmitting || isUploading}
@@ -293,6 +432,7 @@ function WritePageContent() {
{isEditMode ? '수정하기' : '작성하기'} {isEditMode ? '수정하기' : '작성하기'}
</button> </button>
</div> </div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3 space-y-4"> <div className="lg:col-span-3 space-y-4">
@@ -324,6 +464,10 @@ function WritePageContent() {
</div> </div>
</div> </div>
{/* 🛠️ [Legacy] 태그 입력 UI 비활성화
사용자 요청으로 UI에서 숨김 처리 (데이터 구조는 유지)
*/}
{/*
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]"> <div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2"> <h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
🏷️ 태그 🏷️ 태그
@@ -337,8 +481,9 @@ function WritePageContent() {
/> />
<p className="text-xs text-gray-400 mt-2">예: React, Next.js, 튜토리얼</p> <p className="text-xs text-gray-400 mt-2">예: React, Next.js, 튜토리얼</p>
</div> </div>
*/}
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[450px]"> <div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2"> <h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
<ImageIcon size={18} /> <ImageIcon size={18} />
</h3> </h3>