This commit is contained in:
29
package-lock.json
generated
29
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"axios": "^1.13.2",
|
||||
@@ -1745,11 +1746,22 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
|
||||
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.12"
|
||||
},
|
||||
@@ -1761,6 +1773,23 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.91.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.13",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@@ -1,40 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
// 🎨 UX 개선: 알림 라이브러리 추가
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import axios from 'axios';
|
||||
|
||||
// 🛠️ JWT 토큰 만료 여부 체크 함수 (라이브러리 없이 구현)
|
||||
function isTokenExpired(token: string) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
const { exp } = JSON.parse(jsonPayload);
|
||||
|
||||
// 현재 시간(초)이 만료 시간(exp)보다 크거나 같으면 만료됨
|
||||
// 안전 마진 60초 추가 (만료 1분 전이면 미리 갱신)
|
||||
return Date.now() / 1000 >= exp - 60;
|
||||
} catch (e) {
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
|
||||
// 🛡️ 앱 초기화 시 토큰 검증 컴포넌트
|
||||
function AuthInitializer() {
|
||||
const { accessToken, refreshToken, login, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
// 토큰이 없으면 검사할 필요 없음
|
||||
if (!accessToken || !refreshToken) return;
|
||||
|
||||
// 토큰이 만료되었는지 확인
|
||||
if (isTokenExpired(accessToken)) {
|
||||
console.log('🔄 AccessToken expired on init, refreshing...');
|
||||
|
||||
try {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
// 갱신 요청 (http 인터셉터 거치지 않고 직접 axios 사용)
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/api/auth/reissue`,
|
||||
{ accessToken, refreshToken },
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true
|
||||
}
|
||||
);
|
||||
|
||||
if (data.code === 'SUCCESS' && data.data) {
|
||||
// 갱신 성공: 스토어 업데이트
|
||||
login(data.data.accessToken, data.data.refreshToken);
|
||||
console.log('✅ Token refreshed successfully on init');
|
||||
} else {
|
||||
throw new Error('Token refresh response invalid');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to refresh token on init:', error);
|
||||
// 갱신 실패 시 깔끔하게 로그아웃 처리하여 꼬임 방지
|
||||
logout();
|
||||
// 필요 시 로그인 페이지로 이동 (선택 사항)
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, [accessToken, refreshToken, login, logout]);
|
||||
|
||||
return null; // UI를 렌더링하지 않음
|
||||
}
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// 윈도우 포커스 시 자동 리페치 방지 (불필요한 요청 줄임)
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5, // 5분간 데이터 캐싱
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
||||
{children}
|
||||
{/* 🎨 전역 알림 컴포넌트 배치 (상단 중앙) */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#4ade80',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Toaster position="top-right" />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -11,29 +11,50 @@ import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud } fro
|
||||
// 🎨 UX 개선: 토스트 알림 사용
|
||||
import toast from 'react-hot-toast';
|
||||
import dynamic from 'next/dynamic';
|
||||
import axios from 'axios'; // 🆕 직접 갱신 요청을 위해 추가
|
||||
|
||||
const MDEditor = dynamic(
|
||||
() => import('@uiw/react-md-editor').then((mod) => mod.default),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
// 🛠️ 1. 토큰 만료 체크 유틸리티 (JWT 디코딩)
|
||||
function isTokenExpired(token: string) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
const { exp } = JSON.parse(jsonPayload);
|
||||
// 현재 시간보다 만료 시간이 적게 남았거나 지났으면 true (여유시간 30초)
|
||||
return Date.now() / 1000 >= exp - 30;
|
||||
} catch (e) {
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
|
||||
function WritePageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { role, _hasHydrated } = useAuthStore();
|
||||
const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); // 🆕 토큰 관련 상태 가져오기
|
||||
|
||||
const editSlug = searchParams.get('slug');
|
||||
const isEditMode = !!editSlug;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('**Hello world!**');
|
||||
const [content, setContent] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false); // 🆕 제출 중 상태 관리
|
||||
|
||||
useEffect(() => {
|
||||
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
|
||||
toast.error('관리자 권한이 필요합니다.'); // 🎨 Alert 대체
|
||||
toast.error('관리자 권한이 필요합니다.');
|
||||
router.push('/');
|
||||
}
|
||||
}, [role, _hasHydrated, router]);
|
||||
@@ -51,7 +72,8 @@ function WritePageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (existingPost) {
|
||||
setTitle(existingPost.title);
|
||||
// 🛠️ undefined 방지 처리 추가
|
||||
setTitle(existingPost.title || '');
|
||||
setContent(existingPost.content || '');
|
||||
if (categories && existingPost.categoryName) {
|
||||
const found = findCategoryByName(categories, existingPost.categoryName);
|
||||
@@ -78,16 +100,55 @@ function WritePageContent() {
|
||||
if (isEditMode) {
|
||||
queryClient.invalidateQueries({ queryKey: ['post', editSlug] });
|
||||
}
|
||||
// 🎨 UX 개선: 성공 메시지 토스트
|
||||
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
|
||||
router.push(isEditMode ? `/posts/${editSlug}` : '/');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
// 🎨 UX 개선: 에러 메시지 토스트
|
||||
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false); // 🆕 완료 시 로딩 해제
|
||||
}
|
||||
});
|
||||
|
||||
// 🛡️ 2. [핵심 로직] 토큰 검사 및 갱신 보장 함수
|
||||
const ensureAuthToken = async (): Promise<boolean> => {
|
||||
// 1. 토큰이 아예 없으면 실패
|
||||
if (!accessToken || !refreshToken) {
|
||||
toast.error('로그인이 필요합니다.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 토큰이 아직 싱싱하면 바로 통과
|
||||
if (!isTokenExpired(accessToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 만료되었다면 갱신 시도
|
||||
try {
|
||||
console.log('🔄 Access token expired during write. Refreshing...');
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/api/auth/reissue`,
|
||||
{ accessToken, refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' }, withCredentials: true }
|
||||
);
|
||||
|
||||
if (data.code === 'SUCCESS' && data.data) {
|
||||
login(data.data.accessToken, data.data.refreshToken);
|
||||
console.log('✅ Token refreshed successfully before save.');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to refresh token before save:', error);
|
||||
// 갱신 실패: 사용자가 내용을 백업할 수 있도록 경고
|
||||
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !content.trim()) {
|
||||
toast.error('제목과 내용을 입력해주세요.');
|
||||
@@ -98,10 +159,20 @@ function WritePageContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true); // 버튼 비활성화
|
||||
|
||||
// 🛡️ 저장 전 토큰 체크!
|
||||
const isTokenValid = await ensureAuthToken();
|
||||
if (!isTokenValid) {
|
||||
setIsSubmitting(false);
|
||||
return; // 토큰 갱신 실패 시 중단
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
title,
|
||||
content,
|
||||
categoryId: Number(categoryId),
|
||||
tags: [], // 🆕 타입 오류 방지용 빈 배열 (필요 시 태그 입력 기능 추가)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -110,15 +181,18 @@ function WritePageContent() {
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadToast = toast.loading('이미지 업로드 중...'); // 🎨 업로드 로딩 표시
|
||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||
|
||||
try {
|
||||
// 이미지 업로드 전에도 토큰 체크 (선택 사항이지만 안전함)
|
||||
await ensureAuthToken();
|
||||
|
||||
const res = await uploadImage(file);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
const imageUrl = res.data;
|
||||
const markdownImage = ``;
|
||||
setContent((prev) => prev + '\n' + markdownImage);
|
||||
toast.success('이미지가 업로드되었습니다.', { id: uploadToast }); // 로딩 토스트를 성공으로 변경
|
||||
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('이미지 업로드 실패', { id: uploadToast });
|
||||
@@ -142,6 +216,8 @@ function WritePageContent() {
|
||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||
|
||||
try {
|
||||
await ensureAuthToken(); // 토큰 체크
|
||||
|
||||
const res = await uploadImage(file);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
const imageUrl = res.data;
|
||||
@@ -198,10 +274,10 @@ function WritePageContent() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={mutation.isPending || isUploading}
|
||||
disabled={isSubmitting || isUploading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-400 shadow-md hover:shadow-lg transform active:scale-95 duration-200"
|
||||
>
|
||||
{mutation.isPending ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
||||
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
||||
{isEditMode ? '수정하기' : '작성하기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Post {
|
||||
viewCount: number;
|
||||
createdAt: string;
|
||||
content?: string;
|
||||
tags: string[]; // 🆕 태그 배열 속성 추가
|
||||
}
|
||||
|
||||
// 3. 게시글 목록 페이징 응답
|
||||
@@ -36,6 +37,7 @@ export interface AuthResponse {
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId?: number | null; // 🆕 부모 카테고리 ID 추가
|
||||
children: Category[];
|
||||
}
|
||||
|
||||
|
||||
14
yarn.lock
14
yarn.lock
@@ -541,7 +541,19 @@
|
||||
resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz"
|
||||
integrity sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==
|
||||
|
||||
"@tanstack/react-query@^5.90.12":
|
||||
"@tanstack/query-devtools@5.91.1":
|
||||
version "5.91.1"
|
||||
resolved "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz"
|
||||
integrity sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==
|
||||
|
||||
"@tanstack/react-query-devtools@^5.91.1":
|
||||
version "5.91.1"
|
||||
resolved "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz"
|
||||
integrity sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==
|
||||
dependencies:
|
||||
"@tanstack/query-devtools" "5.91.1"
|
||||
|
||||
"@tanstack/react-query@^5.90.10", "@tanstack/react-query@^5.90.12":
|
||||
version "5.90.12"
|
||||
resolved "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz"
|
||||
integrity sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==
|
||||
|
||||
Reference in New Issue
Block a user