From 1b7a26432d3bfaed3cb87a7e695b9c0b04187c02 Mon Sep 17 00:00:00 2001 From: ParkWonYeop Date: Sat, 27 Dec 2025 21:47:34 +0900 Subject: [PATCH] . --- src/app/write/page.tsx | 86 +++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx index c41abe4..a90d2ec 100644 --- a/src/app/write/page.tsx +++ b/src/app/write/page.tsx @@ -8,17 +8,16 @@ import { getCategories } from '@/api/category'; import { uploadImage } from '@/api/image'; import { useAuthStore } from '@/store/authStore'; import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud } from 'lucide-react'; -// 🎨 UX κ°œμ„ : ν† μŠ€νŠΈ μ•Œλ¦Ό μ‚¬μš© import toast from 'react-hot-toast'; import dynamic from 'next/dynamic'; -import axios from 'axios'; // πŸ†• 직접 κ°±μ‹  μš”μ²­μ„ μœ„ν•΄ μΆ”κ°€ +import axios from 'axios'; const MDEditor = dynamic( () => import('@uiw/react-md-editor').then((mod) => mod.default), { ssr: false } ); -// πŸ› οΈ 1. 토큰 만료 체크 μœ ν‹Έλ¦¬ν‹° (JWT λ””μ½”λ”©) +// πŸ› οΈ 1. 토큰 만료 체크 μœ ν‹Έλ¦¬ν‹° function isTokenExpired(token: string) { try { const base64Url = token.split('.')[1]; @@ -30,10 +29,9 @@ function isTokenExpired(token: string) { .join('') ); const { exp } = JSON.parse(jsonPayload); - // ν˜„μž¬ μ‹œκ°„λ³΄λ‹€ 만료 μ‹œκ°„μ΄ 적게 λ‚¨μ•˜κ±°λ‚˜ μ§€λ‚¬μœΌλ©΄ true (μ—¬μœ μ‹œκ°„ 30초) return Date.now() / 1000 >= exp - 30; } catch (e) { - return true; // νŒŒμ‹± μ‹€νŒ¨ μ‹œ 만료된 κ²ƒμœΌλ‘œ κ°„μ£Ό + return true; } } @@ -41,16 +39,17 @@ function WritePageContent() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); // πŸ†• 토큰 κ΄€λ ¨ μƒνƒœ κ°€μ Έμ˜€κΈ° + const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); const editSlug = searchParams.get('slug'); const isEditMode = !!editSlug; const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); + const [content, setContent] = useState('**Hello world!**'); const [categoryId, setCategoryId] = useState(''); + const [tags, setTags] = useState(''); const [isUploading, setIsUploading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); // πŸ†• 제좜 쀑 μƒνƒœ 관리 + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { if (_hasHydrated && (!role || !role.includes('ADMIN'))) { @@ -68,13 +67,17 @@ function WritePageContent() { queryKey: ['post', editSlug], queryFn: () => getPost(editSlug!), enabled: isEditMode, + staleTime: 0, + gcTime: 0, + refetchOnMount: 'always' }); useEffect(() => { if (existingPost) { - // πŸ› οΈ undefined λ°©μ§€ 처리 μΆ”κ°€ setTitle(existingPost.title || ''); setContent(existingPost.content || ''); + setTags(existingPost.tags ? existingPost.tags.join(', ') : ''); + if (categories && existingPost.categoryName) { const found = findCategoryByName(categories, existingPost.categoryName); if (found) setCategoryId(found.id); @@ -95,38 +98,50 @@ function WritePageContent() { const mutation = useMutation({ mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['posts'] }); - if (isEditMode) { - queryClient.invalidateQueries({ queryKey: ['post', editSlug] }); + onSuccess: async (response: any) => { + const savedPost = response?.data; + const newSlug = savedPost?.slug || 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 ? 'κ²Œμ‹œκΈ€μ΄ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.' : 'κ²Œμ‹œκΈ€μ΄ λ°œν–‰λ˜μ—ˆμŠ΅λ‹ˆλ‹€!'); - router.push(isEditMode ? `/posts/${editSlug}` : '/'); + + router.push(isEditMode ? `/posts/${newSlug}` : '/'); }, onError: (err: any) => { toast.error('μ €μž₯ μ‹€νŒ¨: ' + (err.response?.data?.message || err.message)); }, onSettled: () => { - setIsSubmitting(false); // πŸ†• μ™„λ£Œ μ‹œ λ‘œλ”© ν•΄μ œ + 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...'); + 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( @@ -137,13 +152,12 @@ function WritePageContent() { if (data.code === 'SUCCESS' && data.data) { login(data.data.accessToken, data.data.refreshToken); - // console.log('βœ… Token refreshed successfully before save.'); + console.log('βœ… Token refreshed successfully before save.'); return true; } return false; } 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 }); return false; } @@ -159,20 +173,19 @@ function WritePageContent() { return; } - setIsSubmitting(true); // λ²„νŠΌ λΉ„ν™œμ„±ν™” + setIsSubmitting(true); - // πŸ›‘οΈ μ €μž₯ μ „ 토큰 체크! const isTokenValid = await ensureAuthToken(); if (!isTokenValid) { setIsSubmitting(false); - return; // 토큰 κ°±μ‹  μ‹€νŒ¨ μ‹œ 쀑단 + return; } mutation.mutate({ title, content, categoryId: Number(categoryId), - tags: [], // πŸ†• νƒ€μž… 였λ₯˜ λ°©μ§€μš© 빈 λ°°μ—΄ (ν•„μš” μ‹œ νƒœκ·Έ μž…λ ₯ κΈ°λŠ₯ μΆ”κ°€) + tags: tags.split(',').map(t => t.trim()).filter(t => t), }); }; @@ -184,7 +197,6 @@ function WritePageContent() { const uploadToast = toast.loading('이미지 μ—…λ‘œλ“œ 쀑...'); try { - // 이미지 μ—…λ‘œλ“œ 전에도 토큰 체크 (선택 μ‚¬ν•­μ΄μ§€λ§Œ μ•ˆμ „ν•¨) await ensureAuthToken(); const res = await uploadImage(file); @@ -216,7 +228,7 @@ function WritePageContent() { const uploadToast = toast.loading('이미지 μ—…λ‘œλ“œ 쀑...'); try { - await ensureAuthToken(); // 토큰 체크 + await ensureAuthToken(); const res = await uploadImage(file); if (res.code === 'SUCCESS' && res.data) { @@ -311,8 +323,22 @@ function WritePageContent() { {categories ? renderCategoryOptions(categories) :

λ‘œλ”© 쀑...

} + +
+

+ 🏷️ νƒœκ·Έ +

+ 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" + /> +

예: React, Next.js, νŠœν† λ¦¬μ–Ό

+
-
+

이미지 μ—…λ‘œλ“œ