diff --git a/package-lock.json b/package-lock.json
index 27131dd..e3dba12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 8138f22..ee8cef8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 1ec05db..4bcda5e 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -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 (
+ {/* π μ± μ€ν μ ν ν° μλ κ²μ¬ */}
{children}
- {/* π¨ μ μ μλ¦Ό μ»΄ν¬λνΈ λ°°μΉ (μλ¨ μ€μ) */}
-
+
+
);
}
\ No newline at end of file
diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx
index 0e14b8e..b1caf00 100644
--- a/src/app/write/page.tsx
+++ b/src/app/write/page.tsx
@@ -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('');
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 => {
+ // 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() {
diff --git a/src/types/index.ts b/src/types/index.ts
index 5a6ac4a..503840d 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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[];
}
diff --git a/yarn.lock b/yarn.lock
index aa1694e..41a452b 100644
--- a/yarn.lock
+++ b/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==