This commit is contained in:
@@ -8,17 +8,16 @@ 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 } from 'lucide-react';
|
||||||
// 🎨 UX 개선: 토스트 알림 사용
|
|
||||||
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';
|
||||||
|
|
||||||
const MDEditor = dynamic(
|
const MDEditor = dynamic(
|
||||||
() => import('@uiw/react-md-editor').then((mod) => mod.default),
|
() => import('@uiw/react-md-editor').then((mod) => mod.default),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🛠️ 1. 토큰 만료 체크 유틸리티 (JWT 디코딩)
|
// 🛠️ 1. 토큰 만료 체크 유틸리티
|
||||||
function isTokenExpired(token: string) {
|
function isTokenExpired(token: string) {
|
||||||
try {
|
try {
|
||||||
const base64Url = token.split('.')[1];
|
const base64Url = token.split('.')[1];
|
||||||
@@ -30,10 +29,9 @@ function isTokenExpired(token: string) {
|
|||||||
.join('')
|
.join('')
|
||||||
);
|
);
|
||||||
const { exp } = JSON.parse(jsonPayload);
|
const { exp } = JSON.parse(jsonPayload);
|
||||||
// 현재 시간보다 만료 시간이 적게 남았거나 지났으면 true (여유시간 30초)
|
|
||||||
return Date.now() / 1000 >= exp - 30;
|
return Date.now() / 1000 >= exp - 30;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,16 +39,17 @@ function WritePageContent() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); // 🆕 토큰 관련 상태 가져오기
|
const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore();
|
||||||
|
|
||||||
const editSlug = searchParams.get('slug');
|
const editSlug = searchParams.get('slug');
|
||||||
const isEditMode = !!editSlug;
|
const isEditMode = !!editSlug;
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('**Hello world!**');
|
||||||
const [categoryId, setCategoryId] = useState<number | ''>('');
|
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false); // 🆕 제출 중 상태 관리
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
|
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
|
||||||
@@ -68,13 +67,17 @@ function WritePageContent() {
|
|||||||
queryKey: ['post', editSlug],
|
queryKey: ['post', editSlug],
|
||||||
queryFn: () => getPost(editSlug!),
|
queryFn: () => getPost(editSlug!),
|
||||||
enabled: isEditMode,
|
enabled: isEditMode,
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
refetchOnMount: 'always'
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingPost) {
|
if (existingPost) {
|
||||||
// 🛠️ undefined 방지 처리 추가
|
|
||||||
setTitle(existingPost.title || '');
|
setTitle(existingPost.title || '');
|
||||||
setContent(existingPost.content || '');
|
setContent(existingPost.content || '');
|
||||||
|
setTags(existingPost.tags ? existingPost.tags.join(', ') : '');
|
||||||
|
|
||||||
if (categories && existingPost.categoryName) {
|
if (categories && existingPost.categoryName) {
|
||||||
const found = findCategoryByName(categories, existingPost.categoryName);
|
const found = findCategoryByName(categories, existingPost.categoryName);
|
||||||
if (found) setCategoryId(found.id);
|
if (found) setCategoryId(found.id);
|
||||||
@@ -95,38 +98,50 @@ function WritePageContent() {
|
|||||||
|
|
||||||
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: () => {
|
onSuccess: async (response: any) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
const savedPost = response?.data;
|
||||||
if (isEditMode) {
|
const newSlug = savedPost?.slug || editSlug;
|
||||||
queryClient.invalidateQueries({ queryKey: ['post', editSlug] });
|
|
||||||
|
// 🚨 [핵심 수정] 목록 캐시를 '초기화(Reset)'합니다.
|
||||||
|
// 이렇게 하면 목록 페이지(Home, Category 등)로 이동했을 때
|
||||||
|
// 기존 캐시(옛날 글 목록)를 보여주지 않고, 즉시 서버에서 새 데이터를 가져옵니다.
|
||||||
|
await queryClient.resetQueries({ queryKey: ['posts'] });
|
||||||
|
|
||||||
|
// 카테고리 데이터(글 개수 등)도 갱신
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||||
|
|
||||||
|
// 상세 페이지 캐시 삭제 (다시 들어가면 새로 받도록)
|
||||||
|
if (editSlug) {
|
||||||
|
queryClient.removeQueries({ queryKey: ['post', editSlug] });
|
||||||
}
|
}
|
||||||
|
if (newSlug && newSlug !== editSlug) {
|
||||||
|
queryClient.removeQueries({ queryKey: ['post', newSlug] });
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
|
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
|
||||||
router.push(isEditMode ? `/posts/${editSlug}` : '/');
|
|
||||||
|
router.push(isEditMode ? `/posts/${newSlug}` : '/');
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
|
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
setIsSubmitting(false); // 🆕 완료 시 로딩 해제
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🛡️ 2. [핵심 로직] 토큰 검사 및 갱신 보장 함수
|
|
||||||
const ensureAuthToken = async (): Promise<boolean> => {
|
const ensureAuthToken = async (): Promise<boolean> => {
|
||||||
// 1. 토큰이 아예 없으면 실패
|
|
||||||
if (!accessToken || !refreshToken) {
|
if (!accessToken || !refreshToken) {
|
||||||
toast.error('로그인이 필요합니다.');
|
toast.error('로그인이 필요합니다.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 토큰이 아직 싱싱하면 바로 통과
|
|
||||||
if (!isTokenExpired(accessToken)) {
|
if (!isTokenExpired(accessToken)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 만료되었다면 갱신 시도
|
|
||||||
try {
|
try {
|
||||||
// console.log('🔄 Access token expired during write. Refreshing...');
|
console.log('🔄 Access token expired during write. Refreshing...');
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
@@ -137,13 +152,12 @@ function WritePageContent() {
|
|||||||
|
|
||||||
if (data.code === 'SUCCESS' && data.data) {
|
if (data.code === 'SUCCESS' && data.data) {
|
||||||
login(data.data.accessToken, data.data.refreshToken);
|
login(data.data.accessToken, data.data.refreshToken);
|
||||||
// console.log('✅ Token refreshed successfully before save.');
|
console.log('✅ Token refreshed successfully before save.');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('❌ Failed to refresh token before save:', error);
|
console.error('❌ Failed to refresh token before save:', error);
|
||||||
// 갱신 실패: 사용자가 내용을 백업할 수 있도록 경고
|
|
||||||
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 });
|
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -159,20 +173,19 @@ function WritePageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true); // 버튼 비활성화
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// 🛡️ 저장 전 토큰 체크!
|
|
||||||
const isTokenValid = await ensureAuthToken();
|
const isTokenValid = await ensureAuthToken();
|
||||||
if (!isTokenValid) {
|
if (!isTokenValid) {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return; // 토큰 갱신 실패 시 중단
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
categoryId: Number(categoryId),
|
categoryId: Number(categoryId),
|
||||||
tags: [], // 🆕 타입 오류 방지용 빈 배열 (필요 시 태그 입력 기능 추가)
|
tags: tags.split(',').map(t => t.trim()).filter(t => t),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,7 +197,6 @@ function WritePageContent() {
|
|||||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 이미지 업로드 전에도 토큰 체크 (선택 사항이지만 안전함)
|
|
||||||
await ensureAuthToken();
|
await ensureAuthToken();
|
||||||
|
|
||||||
const res = await uploadImage(file);
|
const res = await uploadImage(file);
|
||||||
@@ -216,7 +228,7 @@ function WritePageContent() {
|
|||||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureAuthToken(); // 토큰 체크
|
await ensureAuthToken();
|
||||||
|
|
||||||
const res = await uploadImage(file);
|
const res = await uploadImage(file);
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
if (res.code === 'SUCCESS' && res.data) {
|
||||||
@@ -312,7 +324,21 @@ function WritePageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[300px]">
|
<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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="태그 입력 (콤마 , 로 구분)"
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">예: React, Next.js, 튜토리얼</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[450px]">
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user