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
3 changes: 2 additions & 1 deletion app/admin/write/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Suspense } from 'react';
import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator';
import BlogFormSkeleton from '@/app/entities/common/Skeleton/BlogFormSkeleton';

interface LayoutProps {
children: React.ReactNode;
}
const Layout = ({ children }: LayoutProps) => {
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
return <Suspense fallback={<BlogFormSkeleton />}>{children}</Suspense>;
};

export default Layout;
1 change: 0 additions & 1 deletion app/admin/write/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export const generateMetadata = async (): Promise<Metadata> => {
const BlogWritePage = () => {
return (
<section className={'p-6 max-w-7xl mx-auto'}>
<h1 className={'text-2xl text-center mb-4'}>글 작성</h1>
<BlogForm />
</section>
);
Expand Down
12 changes: 10 additions & 2 deletions app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ export async function POST(req: Request) {
author,
content,
profileImage,
thumbnailImage,
seriesId,
tags,
isPrivate,
Expand All @@ -131,6 +130,15 @@ export async function POST(req: Request) {
);
}

let thumbnailOfPost = getThumbnailInMarkdown(content);
if (!thumbnailOfPost) {
const defaultSeriesThumbnail =
await Series.findById(seriesId).select('thumbnailImage');
thumbnailOfPost =
defaultSeriesThumbnail?.thumbnailImage ||
'/images/placeholder/thumbnail_example2.webp';
}

const post = {
slug: await generateUniqueSlug(title, Post),
title,
Expand All @@ -139,7 +147,7 @@ export async function POST(req: Request) {
content,
timeToRead: Math.ceil(content.length / 500),
profileImage,
thumbnailImage: thumbnailImage || getThumbnailInMarkdown(content),
thumbnailImage: thumbnailOfPost,
seriesId: seriesId || null,
tags: tags || [],
isPrivate: isPrivate || false,
Expand Down
8 changes: 6 additions & 2 deletions app/entities/common/Loading/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { AiOutlineLoading3Quarters } from 'react-icons/ai';

interface LoadingSpinnerProps {
content?: string;
size?: number;
}

const LoadingSpinner = ({ content }: LoadingSpinnerProps) => {
const LoadingSpinner = ({ content, size }: LoadingSpinnerProps) => {
return (
<div className=" text-primary" role="status">
<AiOutlineLoading3Quarters className={'animate-spin '} />
<AiOutlineLoading3Quarters
className={'animate-spin'}
style={{ fontSize: size }}
/>
<span className="hidden">{content ? content : 'Loading...'}</span>
</div>
);
Expand Down
62 changes: 62 additions & 0 deletions app/entities/common/Skeleton/BlogFormSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Skeleton from '@/app/entities/common/Skeleton/Skeleton';

const BlogFormSkeleton = () => {
return (
<div className={'px-16'}>
<h1 className={'text-2xl text-center mb-4'}>
<Skeleton className="h-8 w-32 mx-auto" />
</h1>
<div className="mb-6">
<div className="flex mb-4 gap-1 items-center">
<span className="font-bold text-default flex-shrink-0">
<Skeleton className="h-8 w-20" />
</span>
<Skeleton className="h-8 flex-grow" />
</div>
<div className="flex mb-4 gap-1 items-center">
<span className="font-bold text-default flex-shrink-0">
<Skeleton className="h-8 w-20" />
</span>
<Skeleton className="h-8 flex-grow" />
</div>
<div className="flex justify-start items-center">
<div className="flex flex-wrap mb-4 gap-1 items-center">
<span className="w-12 font-bold mr-3 flex-shrink text-nowrap flex-nowrap">
<Skeleton className="h-8 w-16" />
</span>
<Skeleton className="h-8 w-32 rounded-full" />
<Skeleton className="h-8 w-24 rounded-full" />
<Skeleton className="h-8 w-28 rounded-full" />
</div>
</div>
<div className="flex items-center w-full gap-2 mb-4">
<div className="w-1/2 flex justify-start items-center gap-6">
<div className="inline-flex items-center text-nowrap gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-10 w-40" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-20" />
<Skeleton className="h-4 w-4" />
</div>
</div>
<Skeleton className="h-8 w-32 rounded-full" />
<Skeleton className="h-8 w-28 rounded-full" />
<Skeleton className="h-8 w-32 rounded-full" />
</div>
</div>
<div className="mb-4">
<Skeleton className="h-[500px] w-full rounded-lg" />
</div>
<div className="mb-4">
<Skeleton className="h-32 w-full rounded-lg" />
</div>
<div className="flex justify-end gap-2 mt-6">
<Skeleton className="h-10 w-24 rounded-md" />
<Skeleton className="h-10 w-24 rounded-md" />
</div>
</div>
);
};

export default BlogFormSkeleton;
52 changes: 52 additions & 0 deletions app/entities/common/Typography/TypingText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';

interface TypingTextProps {
title: string;
delay?: number;
className?: string;
onComplete?: () => void;
}

const TypingText = ({
title,
delay = 50,
onComplete,
className,
}: TypingTextProps) => {
const [displayTitle, setDisplayTitle] = useState('');
const [isTypingComplete, setIsTypingComplete] = useState(false);

useEffect(() => {
if (!title) return;

let currentIndex = 0;
const interval = setInterval(() => {
if (currentIndex <= title.length) {
setDisplayTitle(title.slice(0, currentIndex));
currentIndex++;
} else {
clearInterval(interval);
setIsTypingComplete(true);
}
}, delay);

return () => clearInterval(interval);
}, [title]);

useEffect(() => {
if (isTypingComplete && onComplete) {
onComplete();
}
}, [isTypingComplete, onComplete]);

return (
<p className="typing-text">
{displayTitle}
{!isTypingComplete && (
<span className="inline-block w-1 h-8 ml-1 bg-black animate-blink" />
)}
</p>
);
};

export default TypingText;
5 changes: 4 additions & 1 deletion app/entities/post/detail/PostDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import PostBody from '@/app/entities/post/detail/PostBody';

interface PostArticleProps {
post: PostType;
isAdmin?: boolean;
}

const PostDetail = ({ post }: PostArticleProps) => {
const PostDetail = ({ post, isAdmin = false }: PostArticleProps) => {
const defaultThumbnail = '/images/placeholder/thumbnail_example2.webp';
return (
<article className="post">
<PostHeader
title={post?.title || ''}
subTitle={post?.subTitle || ''}
slug={post?.slug || ''}
author={post?.author || ''}
date={post?.date || 0}
timeToRead={post?.timeToRead || 0}
backgroundThumbnail={post?.thumbnailImage || defaultThumbnail}
isAdmin={isAdmin}
/>
<PostBody
loading={false}
Expand Down
41 changes: 20 additions & 21 deletions app/entities/post/detail/PostHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,36 @@ import Image from 'next/image';
import { StaticImport } from 'next/dist/shared/lib/get-img-props';
import Profile from '@/app/entities/common/Profile';
import { FaBook } from 'react-icons/fa';
import { useSession } from 'next-auth/react';
import TypingText from '../../common/Typography/TypingText';

interface Props {
title: string;
subTitle: string;
slug: string;
author: string;
date: number;
timeToRead: number;
backgroundThumbnail?: StaticImport | string;
isAdmin?: boolean;
}

const PostHeader = ({
title,
subTitle,
slug,
author,
date,
timeToRead,
backgroundThumbnail,
isAdmin = false,
}: Props) => {
const [displayTitle, setDisplayTitle] = useState('');
const [isTypingComplete, setIsTypingComplete] = useState(false);

useEffect(() => {
if (!title) return;

let currentIndex = 0;
const interval = setInterval(() => {
if (currentIndex <= title.length) {
setDisplayTitle(title.slice(0, currentIndex));
currentIndex++;
} else {
clearInterval(interval);
setIsTypingComplete(true);
}
}, 50);

return () => clearInterval(interval);
}, [title]);
const handleEditClick = () => {
const editUrl = `/admin/write?slug=${slug}`;
window.open(editUrl, '_blank')?.focus();
};

return (
<div
Expand Down Expand Up @@ -76,10 +69,11 @@ const PostHeader = ({
'font-bold mb-4 pt-10 md:pt-20 text-3xl md:text-5xl z-10 px-2 break-keep'
}
>
{displayTitle}
{!isTypingComplete && (
<span className="inline-block w-1 h-6 ml-1 bg-black animate-blink" />
)}
<TypingText
title={title}
delay={50}
onComplete={() => setIsTypingComplete(true)}
/>
</h1>
<h2
className={`md:text-2xl font-bold mb-4 transition-opacity duration-500 ${
Expand Down Expand Up @@ -108,6 +102,11 @@ const PostHeader = ({
<FaBook />
{timeToRead} min read
</span>
{isAdmin && (
<button onClick={handleEditClick}>
<span className="underline">Edit</span>
</button>
)}
</div>
</div>
</div>
Expand Down
Loading