From a753e7614ac9622d9a3327b05a5f1340d4da0474 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sun, 9 Aug 2026 13:40:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=85=B8=20=EB=A1=9C=EB=94=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EB=A6=AC=EB=A7=88=EC=9A=B4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blog/loading.tsx 전체 화면 fallback 삭제 - Sidebar/Header를 정적 import로 전환해 네비게이션 깜빡임 완화 Co-authored-by: Cursor --- app/(public)/blog/(posts)/layout.tsx | 6 +-- app/(public)/blog/loading.tsx | 74 ---------------------------- 2 files changed, 2 insertions(+), 78 deletions(-) delete mode 100644 app/(public)/blog/loading.tsx diff --git a/app/(public)/blog/(posts)/layout.tsx b/app/(public)/blog/(posts)/layout.tsx index c3881fe2..ac6276ef 100644 --- a/app/(public)/blog/(posts)/layout.tsx +++ b/app/(public)/blog/(posts)/layout.tsx @@ -1,9 +1,7 @@ import { SpaceBackground } from '@/widgets/post/ui'; +import BlogHeader from '@/widgets/post/ui/BlogHeader'; +import Sidebar from '@/widgets/post/ui/Sidebar'; import type { Metadata } from 'next'; -import dynamic from 'next/dynamic'; - -const BlogHeader = dynamic(() => import('@/widgets/post/ui/BlogHeader')); -const Sidebar = dynamic(() => import('@/widgets/post/ui/Sidebar')); export const metadata: Metadata = { title: 'Blog', diff --git a/app/(public)/blog/loading.tsx b/app/(public)/blog/loading.tsx deleted file mode 100644 index 23bd73d2..00000000 --- a/app/(public)/blog/loading.tsx +++ /dev/null @@ -1,74 +0,0 @@ -export default function Loading() { - return ( -
-
- {/* Core sphere */} -
- - {/* Ring 1 - 가장 큰 링 */} -
- - {/* Ring 2 - 중간 링 */} -
- - {/* Ring 3 - 작은 링 */} -
- - -
-
- ); -} From 1f69b7bb15f1aa2528a9b88cf6e9122a295ebd61 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sun, 9 Aug 2026 13:40:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=8F=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=B3=B8=EB=AC=B8=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사이드바는 유지한 채 본문만 즉시 전환되도록 loading.tsx 배치 - 동적 라우트 프리페치 시 즉시 전환 UI 제공 Co-authored-by: Cursor --- .../[slug]/post/[postSlug]/loading.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 app/(public)/blog/(posts)/category/[slug]/post/[postSlug]/loading.tsx diff --git a/app/(public)/blog/(posts)/category/[slug]/post/[postSlug]/loading.tsx b/app/(public)/blog/(posts)/category/[slug]/post/[postSlug]/loading.tsx new file mode 100644 index 00000000..b10f0236 --- /dev/null +++ b/app/(public)/blog/(posts)/category/[slug]/post/[postSlug]/loading.tsx @@ -0,0 +1,42 @@ +export default function PostDetailLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {['w88-1', 'w76-1', 'w64-1', 'w52-1', 'w88-2', 'w76-2', 'w64-2', 'w52-2'].map((id) => { + const width = Number(id.slice(1, 3)); + return ( +
+ ); + })} +
+
+
+
+ ); +} From 29cd56cb9f534af89d798bdb074016646ee7857d Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sun, 9 Aug 2026 13:40:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?update:=20=ED=8F=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EC=9D=84=20Link+pending=20UX=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - router.push/ 대신 Link prefetch 사용 - 클릭 즉시 스피너·opacity로 이동 중 피드백 표시 Co-authored-by: Cursor --- .../blog/ui/PostListCard/PostListCard.tsx | 88 +++++++++++++------ src/widgets/post/ui/RecentPosts.tsx | 86 ++++++++++++------ 2 files changed, 122 insertions(+), 52 deletions(-) diff --git a/src/features/blog/ui/PostListCard/PostListCard.tsx b/src/features/blog/ui/PostListCard/PostListCard.tsx index 2dc4dfd0..1bacc0b1 100644 --- a/src/features/blog/ui/PostListCard/PostListCard.tsx +++ b/src/features/blog/ui/PostListCard/PostListCard.tsx @@ -4,8 +4,10 @@ import type { PostResponse } from '@/entities/post/model/post'; import { Tag } from '@/shared/ui'; import { formatDateToYMD } from '@/shared/utils'; import { motion } from 'framer-motion'; -import { Eye } from 'lucide-react'; +import { Eye, LoaderCircle } from 'lucide-react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import { useTransition } from 'react'; type PostListCardProps = { post: PostResponse; @@ -15,34 +17,66 @@ type PostListCardProps = { export const PostListCard = ({ post, categoryName, categorySlug }: PostListCardProps) => { const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const href = `/blog/category/${categorySlug ?? post.category?.slug}/post/${post?.slug}`; + + const handleNavigate = (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + + event.preventDefault(); + startTransition(() => { + router.push(href); + }); + }; return ( - router.push(`/blog/category/${categorySlug ?? post.category?.slug}/post/${post?.slug}`)} + router.prefetch(href)} + aria-busy={isPending} + className={`block rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 transition-opacity ${ + isPending ? 'pointer-events-none opacity-60' : '' + }`} > -
- - #{categoryName ?? post.category?.name} - - - - {post?.views ?? 0} - -
-

{post?.title}

-
- {post?.author ? post?.author?.name : '관리자'} - {formatDateToYMD(post?.updatedAt ?? '')} -
-
-
+ + {isPending && ( + + + + )} +
+ + #{categoryName ?? post.category?.name} + + + + {post?.views ?? 0} + +
+

{post?.title}

+
+ {post?.author ? post?.author?.name : '관리자'} + {formatDateToYMD(post?.updatedAt ?? '')} +
+
+
+ ); }; diff --git a/src/widgets/post/ui/RecentPosts.tsx b/src/widgets/post/ui/RecentPosts.tsx index 15279924..f4ab293f 100644 --- a/src/widgets/post/ui/RecentPosts.tsx +++ b/src/widgets/post/ui/RecentPosts.tsx @@ -1,8 +1,11 @@ -import { useState } from 'react'; +import { useState, useTransition } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { PostCardSkeleton } from '@/shared/ui/skeleton'; import { PostCard } from '@/features/blog/ui'; import type { PostResponse } from '@/entities/post/model/post'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { LoaderCircle } from 'lucide-react'; type RecentPostsProps = { posts: PostResponse[]; @@ -10,14 +13,17 @@ type RecentPostsProps = { }; export const RecentPosts = ({ posts, isLoading }: RecentPostsProps) => { + const router = useRouter(); const [hoveredIndex, setHoveredIndex] = useState(null); + const [pendingHref, setPendingHref] = useState(null); + const [isPending, startTransition] = useTransition(); const recentPosts = posts.slice(0, 6); if (isLoading) { return (
- {Array.from({ length: 6 }).map((_) => ( - + {['a', 'b', 'c', 'd', 'e', 'f'].map((id) => ( + ))}
); @@ -29,29 +35,59 @@ export const RecentPosts = ({ posts, isLoading }: RecentPostsProps) => { return (
- {recentPosts.map((post, idx) => ( - setHoveredIndex(idx)} - onMouseLeave={() => setHoveredIndex(null)} - > - - {hoveredIndex === idx && ( - + {recentPosts.map((post, idx) => { + const href = `/blog/category/${post.category?.slug}/post/${post.slug}`; + const isCardPending = isPending && pendingHref === href; + + const handleNavigate = (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + + event.preventDefault(); + setPendingHref(href); + startTransition(() => { + router.push(href); + }); + }; + + return ( + { + setHoveredIndex(idx); + router.prefetch(href); + }} + onMouseLeave={() => setHoveredIndex(null)} + aria-busy={isCardPending} + className={`relative group block p-2 h-full w-full transition-opacity ${ + isCardPending ? 'pointer-events-none opacity-60' : '' + }`} + > + {isCardPending && ( + + + )} - - - - ))} + + {hoveredIndex === idx && ( + + )} + + + + ); + })}
); }; From f7b2d7ee0279ef8faf13672a6819a170d992caed Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sun, 9 Aug 2026 13:40:50 +0900 Subject: [PATCH 4/4] =?UTF-8?q?perf:=20getPostBySlug=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React cache로 metadata와 page의 동일 slug fetch를 공유 Co-authored-by: Cursor --- src/features/post/api/post-api.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/post/api/post-api.ts b/src/features/post/api/post-api.ts index e6e16a1b..af868509 100644 --- a/src/features/post/api/post-api.ts +++ b/src/features/post/api/post-api.ts @@ -1,6 +1,7 @@ import { type ApiRequest, fetcher } from '@/shared/api'; import type { Post } from '@prisma/client'; import { getSession } from 'next-auth/react'; +import { cache } from 'react'; import type { GetPostListParams, GetPostSummaryRequest } from '../model'; import type { PostResponse } from '@/entities/post/model/post'; @@ -63,12 +64,14 @@ export const deletePost = (id: number) => method: 'delete', }); -export const getPostBySlug = (slug: string): Promise => +/** generateMetadata + page가 같은 요청에서 한 번만 fetch 하도록 cache */ +export const getPostBySlug = cache((slug: string): Promise => fetcher({ url: '/api/posts/{slug}', path: { slug }, method: 'get', - }); + }), +); export const getPostSummary = (body: GetPostSummaryRequest) => fetcher({