Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export default function PostDetailLoading() {
return (
<div
className='max-w-4xl mx-4 lg:mx-auto mt-4 mb-12'
aria-busy='true'
aria-label='포스트 불러오는 중'
>
<section className='relative md:bg-gradient-to-br md:from-[#181c2a]/90 md:via-[#232946]/90 md:to-[#232946]/80 md:border md:border-blue-400/30 md:rounded-2xl md:shadow-[0_0_24px_4px_#7dd3fc22] px-4 sm:px-6 md:px-8 py-8 overflow-hidden'>
<div className='space-y-4'>
<div className='h-9 w-3/4 max-w-xl rounded-lg bg-blue-400/20 animate-pulse' />
<div className='flex items-center gap-3'>
<div className='h-3 w-20 rounded bg-blue-400/15 animate-pulse' />
<div className='h-3 w-24 rounded bg-blue-400/15 animate-pulse' />
<div className='h-3 w-16 rounded bg-blue-400/15 animate-pulse' />
</div>
<div className='flex flex-wrap gap-2 pt-2'>
<div className='h-6 w-14 rounded-full bg-blue-500/20 animate-pulse' />
<div className='h-6 w-16 rounded-full bg-blue-500/20 animate-pulse' />
<div className='h-6 w-12 rounded-full bg-blue-500/20 animate-pulse' />
</div>
<div className='mt-6 space-y-3 rounded-xl border border-blue-400/20 bg-[#181c2a]/40 p-4'>
<div className='h-4 w-24 rounded bg-cyan-400/20 animate-pulse' />
<div className='h-3 w-full rounded bg-blue-400/15 animate-pulse' />
<div className='h-3 w-5/6 rounded bg-blue-400/15 animate-pulse' />
</div>
<div className='mt-8 space-y-3'>
{['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 (
<div
key={id}
className='h-4 rounded bg-blue-400/15 animate-pulse'
style={{ width: `${width}%` }}
/>
);
})}
</div>
</div>
</section>
</div>
);
}
6 changes: 2 additions & 4 deletions app/(public)/blog/(posts)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
74 changes: 0 additions & 74 deletions app/(public)/blog/loading.tsx

This file was deleted.

88 changes: 61 additions & 27 deletions src/features/blog/ui/PostListCard/PostListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}

event.preventDefault();
startTransition(() => {
router.push(href);
});
};

return (
<motion.div
key={`${post.id}-list`}
whileHover={{
scale: 1.015,
boxShadow: '0 0 16px #7dd3fc, 0 0 32px #7dd3fc55',
backgroundColor: '#232946cc',
}}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
className='rounded-lg px-4 py-3 transition cursor-pointer'
onClick={() => router.push(`/blog/category/${categorySlug ?? post.category?.slug}/post/${post?.slug}`)}
<Link
href={href}
prefetch
onClick={handleNavigate}
onMouseEnter={() => 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' : ''
}`}
>
<div className='flex items-center justify-between'>
<Tag color='neon' spacing='tight'>
#{categoryName ?? post.category?.name}
</Tag>
<span className='flex items-center justify-center gap-1 text-blue-300 text-xs'>
<Eye size={15} className='inline-block' />
{post?.views ?? 0}
</span>
</div>
<h2 className='text-lg font-extrabold text-blue-100 mt-1'>{post?.title}</h2>
<div className='flex items-center justify-between mt-2 text-xs text-blue-200'>
<span>{post?.author ? post?.author?.name : '관리자'}</span>
<span>{formatDateToYMD(post?.updatedAt ?? '')}</span>
</div>
<hr className='my-6 border-blue-900/40' />
</motion.div>
<motion.div
key={`${post.id}-list`}
whileHover={
isPending
? undefined
: {
scale: 1.015,
boxShadow: '0 0 16px #7dd3fc, 0 0 32px #7dd3fc55',
backgroundColor: '#232946cc',
}
}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
className='rounded-lg px-4 py-3 transition cursor-pointer relative'
>
{isPending && (
<span className='absolute right-4 top-4 text-cyan-300' aria-hidden>
<LoaderCircle className='size-4 animate-spin' />
</span>
)}
<div className='flex items-center justify-between'>
<Tag color='neon' spacing='tight'>
#{categoryName ?? post.category?.name}
</Tag>
<span className='flex items-center justify-center gap-1 text-blue-300 text-xs'>
<Eye size={15} className='inline-block' />
{post?.views ?? 0}
</span>
</div>
<h2 className='text-lg font-extrabold text-blue-100 mt-1'>{post?.title}</h2>
<div className='flex items-center justify-between mt-2 text-xs text-blue-200'>
<span>{post?.author ? post?.author?.name : '관리자'}</span>
<span>{formatDateToYMD(post?.updatedAt ?? '')}</span>
</div>
<hr className='my-6 border-blue-900/40' />
</motion.div>
</Link>
);
};
7 changes: 5 additions & 2 deletions src/features/post/api/post-api.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -63,12 +64,14 @@ export const deletePost = (id: number) =>
method: 'delete',
});

export const getPostBySlug = (slug: string): Promise<PostResponse> =>
/** generateMetadata + page가 같은 요청에서 한 번만 fetch 하도록 cache */
export const getPostBySlug = cache((slug: string): Promise<PostResponse> =>
fetcher({
url: '/api/posts/{slug}',
path: { slug },
method: 'get',
});
}),
);

export const getPostSummary = (body: GetPostSummaryRequest) =>
fetcher({
Expand Down
86 changes: 61 additions & 25 deletions src/widgets/post/ui/RecentPosts.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
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[];
isLoading: boolean;
};

export const RecentPosts = ({ posts, isLoading }: RecentPostsProps) => {
const router = useRouter();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const recentPosts = posts.slice(0, 6);

if (isLoading) {
return (
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4 mb-10'>
{Array.from({ length: 6 }).map((_) => (
<PostCardSkeleton key={`skeleton-card-${Math.random()}`} />
{['a', 'b', 'c', 'd', 'e', 'f'].map((id) => (
<PostCardSkeleton key={`skeleton-card-${id}`} />
))}
</div>
);
Expand All @@ -29,29 +35,59 @@ export const RecentPosts = ({ posts, isLoading }: RecentPostsProps) => {

return (
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4 mb-10'>
{recentPosts.map((post, idx) => (
<a
href={`/blog/category/${post.category?.slug}/post/${post.slug}`}
key={post.createdAt}
className='relative group block p-2 h-full w-full'
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
>
<AnimatePresence>
{hoveredIndex === idx && (
<motion.span
className='absolute inset-0 h-full w-full bg-slate-600/60 dark:bg-[#232946]/95 backdrop-blur-md block rounded-3xl pointer-events-none'
layoutId='hoverBackground'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15 } }}
exit={{ opacity: 0, transition: { duration: 0.15, delay: 0.2 } }}
style={{ willChange: 'opacity, background' }}
/>
{recentPosts.map((post, idx) => {
const href = `/blog/category/${post.category?.slug}/post/${post.slug}`;
const isCardPending = isPending && pendingHref === href;

const handleNavigate = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}

event.preventDefault();
setPendingHref(href);
startTransition(() => {
router.push(href);
});
};

return (
<Link
href={href}
key={post.id ?? post.createdAt}
prefetch
onClick={handleNavigate}
onMouseEnter={() => {
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 && (
<span className='absolute right-3 top-3 z-20 text-cyan-300' aria-hidden>
<LoaderCircle className='size-4 animate-spin' />
</span>
)}
<PostCard post={post} />
</AnimatePresence>
</a>
))}
<AnimatePresence>
{hoveredIndex === idx && (
<motion.span
className='absolute inset-0 h-full w-full bg-slate-600/60 dark:bg-[#232946]/95 backdrop-blur-md block rounded-3xl pointer-events-none'
layoutId='hoverBackground'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15 } }}
exit={{ opacity: 0, transition: { duration: 0.15, delay: 0.2 } }}
style={{ willChange: 'opacity, background' }}
/>
)}
<PostCard post={post} />
</AnimatePresence>
</Link>
);
})}
</div>
);
};
Loading