Skip to content

Commit cf9e742

Browse files
Merge pull request #30 from ShipFriend0516/feature/write-safety
[Feature] 글쓰기 페이지 새로고침 안되게 막기
2 parents b082d86 + 869b264 commit cf9e742

File tree

22 files changed

+331
-69
lines changed

22 files changed

+331
-69
lines changed

app/__test__/utils/utils.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { getThumbnailInMarkdown } from '@/app/lib/utils/parse';
2-
2+
import { createPostSlug, generateUniqueSlug } from '@/app/lib/utils/post';
3+
import type { Post } from '@/app/types/Post';
4+
import { Model } from 'mongoose';
35
describe('마크다운에서 이미지 경로 추출 함수 테스트', () => {
46
it('마크다운에서 이미지 경로 추출', () => {
57
const content = `
@@ -19,3 +21,44 @@ describe('마크다운에서 이미지 경로 추출 함수 테스트', () => {
1921
expect(thumbnail).toBeNull();
2022
});
2123
});
24+
25+
describe('블로그 slug 생성 함수 테스트', () => {
26+
test('한글 제목을 slug로 변환', () => {
27+
const title = '한글 제목';
28+
const slug = createPostSlug(title);
29+
expect(slug).toBe('한글-제목');
30+
});
31+
32+
test('영어 제목을 slug로 변환', () => {
33+
const title = 'English Title';
34+
const slug = createPostSlug(title);
35+
expect(slug).toBe('english-title');
36+
});
37+
38+
test('특수문자 제목을 slug로 변환', () => {
39+
const title = '특수문자!@#제목';
40+
const slug = createPostSlug(title);
41+
expect(slug).toBe('특수문자-제목');
42+
});
43+
44+
test('마지막 문자가 ?로 끝나는 경우 제거', () => {
45+
const title = '물음표?';
46+
const slug = createPostSlug(title);
47+
expect(slug).toBe('물음표');
48+
});
49+
50+
test('중복된 slug 생성', async () => {
51+
const title = '중복된 제목';
52+
const Post = {
53+
findOne: jest
54+
.fn()
55+
.mockReturnValueOnce({ slug: '중복된-제목' })
56+
.mockReturnValueOnce(null),
57+
};
58+
const slug = await generateUniqueSlug(
59+
title,
60+
Post as unknown as Model<Post>
61+
);
62+
expect(slug).toBe('중복된-제목-1');
63+
});
64+
});

app/admin/write/page.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
'use client';
22
import BlogForm from '@/app/entities/post/write/BlogForm';
33
import axios from 'axios';
4-
import { Post } from '@/app/types/Post';
4+
import { PostBody } from '@/app/types/Post';
55
import { useRouter, useSearchParams } from 'next/navigation';
66
import useToast from '@/app/hooks/useToast';
77

8-
type PostBody = Omit<Post, '_id' | 'date' | 'timeToRead' | 'comment'>;
9-
108
const BlogWritePage = () => {
119
const router = useRouter();
1210
const params = useSearchParams();

app/api/posts/[postId]/route.ts renamed to app/api/posts/[slug]/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
// app/api/posts/[postId]/route.ts
1+
// app/api/posts/[slug]/route.ts
22
import dbConnect from '@/app/lib/dbConnect';
33
import Post from '@/app/models/Post';
44
import { NextRequest } from 'next/server';
55

66
export async function GET(
77
req: NextRequest,
8-
{ params }: { params: { postId: string } }
8+
{ params }: { params: { slug: string } }
99
) {
1010
try {
1111
await dbConnect();
12-
const post = await Post.findById(params.postId).lean();
12+
const post = await Post.findOne({ slug: params.slug }).lean();
1313

1414
if (!post) {
1515
return Response.json(
@@ -30,13 +30,13 @@ export async function GET(
3030

3131
export async function PUT(
3232
req: NextRequest,
33-
{ params }: { params: { postId: string } }
33+
{ params }: { params: { slug: string } }
3434
) {
3535
try {
3636
await dbConnect();
3737
const body = await req.json();
3838

39-
const updatedPost = await Post.findByIdAndUpdate(params.postId, body, {
39+
const updatedPost = await Post.findByIdAndUpdate(params.slug, body, {
4040
new: true,
4141
runValidators: true,
4242
}).lean();
@@ -60,11 +60,11 @@ export async function PUT(
6060

6161
export async function DELETE(
6262
req: NextRequest,
63-
{ params }: { params: { postId: string } }
63+
{ params }: { params: { slug: string } }
6464
) {
6565
try {
6666
await dbConnect();
67-
const deletedPost = await Post.findByIdAndDelete(params.postId).lean();
67+
const deletedPost = await Post.findByIdAndDelete(params.slug).lean();
6868

6969
if (!deletedPost) {
7070
return Response.json(

app/api/posts/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Post from '@/app/models/Post';
22
import dbConnect from '@/app/lib/dbConnect';
33
import { getServerSession } from 'next-auth/next';
44
import { getThumbnailInMarkdown } from '@/app/lib/utils/parse';
5+
import { generateUniqueSlug } from '@/app/lib/utils/post';
56

67
// GET /api/posts - 모든 글 조회
78
export async function GET() {
@@ -40,6 +41,7 @@ export async function POST(req: Request) {
4041
}
4142

4243
const post = {
44+
slug: await generateUniqueSlug(title, Post),
4345
title,
4446
subTitle,
4547
author,

app/entities/post/api/postAPI.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import { Post } from '@/app/types/Post';
44
interface GetPostDetailResponse {
55
post: Post;
66
}
7+
const URL = process.env.NEXT_PUBLIC_URL;
78

89
export const getPostDetail = async (
9-
postId: string
10+
slug: string
1011
): Promise<GetPostDetailResponse> => {
11-
const response = await axios.get(`/api/posts/${postId}`);
12+
const response = await axios.get(`${URL}/api/posts/${slug}`);
1213

1314
return response.data;
1415
};
1516

1617
export const deletePost = async (postId: string) => {
17-
return await axios.delete(`/api/posts/${postId}`);
18+
return await axios.delete(`${URL}/api/posts/${postId}`);
1819
};

app/entities/post/detail/PostBody.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
'use client';
12
import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator';
23
import MDEditor from '@uiw/react-md-editor';
34

@@ -8,7 +9,7 @@ interface Props {
89

910
const PostBody = ({ content, loading }: Props) => {
1011
return (
11-
<div className={'w-full post-body px-4 py-16 pb-52 min-h-[500px]'}>
12+
<div className={'w-full post-body px-4 py-16 min-h-[500px]'}>
1213
{loading ? (
1314
<div className={'w-1/3 mx-auto'}>
1415
<LoadingIndicator />

app/entities/post/detail/PostHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ const PostHeader = ({
4747
return (
4848
<div
4949
className={
50-
'post-header h-[220px] md:h-[292px] relative overflow-hidden w-full text-center'
50+
'post-header h-[220px] md:h-[292px] relative overflow-hidden w-full text-center bg-gray-400/50'
5151
}
5252
>
53-
<h1 className={'font-bold mb-4 pt-10 md:pt-20 text-3xl lg:text-5xl'}>
53+
<h1 className={'font-bold mb-4 pt-10 md:pt-20 text-3xl lg:text-5xl '}>
5454
{displayTitle}
5555
{!isTypingComplete && (
5656
<span className="inline-block w-1 h-6 ml-1 bg-black animate-blink" />
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Post } from '@/app/types/Post';
2+
3+
const PostJSONLd = ({ post }: { post: Post }) => {
4+
return (
5+
<script
6+
type="application/ld+json"
7+
dangerouslySetInnerHTML={{
8+
__html: JSON.stringify({
9+
'@context': 'https://schema.org',
10+
'@type': 'BlogPosting',
11+
headline: post.title,
12+
alternativeHeadline: post.subTitle, // 서브타이틀용
13+
description: post.subTitle || post.content.substring(0, 160),
14+
articleBody: post.content, // 본문 전체
15+
author: {
16+
'@type': 'Person',
17+
name: post.author,
18+
},
19+
datePublished: new Date(post.date).toISOString(),
20+
dateModified: new Date(post.updatedAt || post.date).toISOString(),
21+
wordCount: post.content.split(/\s+/g).length,
22+
timeRequired: `PT${post.timeToRead}M`, // ISO 8601 duration format
23+
// 이미지가 있는 경우
24+
image: post.thumbnailImage,
25+
// 블로그/사이트 정보
26+
publisher: {
27+
'@type': 'Organization',
28+
name: 'ShipFriend TechBlog',
29+
logo: {
30+
'@type': 'ImageObject',
31+
url: `https://oraciondev.vercel.app/favicon.ico`,
32+
},
33+
},
34+
}),
35+
}}
36+
/>
37+
);
38+
};
39+
export default PostJSONLd;

app/entities/post/list/PostList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const PostList = (props: { loading: boolean; posts: Post[] | undefined }) => {
2222
<li key={post._id}>
2323
<PostPreview
2424
_id={post._id}
25+
slug={post.slug}
2526
title={post.title}
2627
subTitle={post.subTitle}
2728
author={post.author}

app/entities/post/list/PostPreview.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { AiOutlineLoading3Quarters } from 'react-icons/ai';
1010
import Link from 'next/link';
1111

1212
const PostPreview = ({
13-
_id,
13+
slug,
1414
title,
1515
subTitle,
1616
author,
@@ -21,7 +21,7 @@ const PostPreview = ({
2121
const [isLoading, setIsLoading] = useState(true);
2222

2323
return (
24-
<Link href={`/posts/${_id}`} className={' mx-auto '}>
24+
<Link href={`/posts/${slug}`} className={' mx-auto '}>
2525
<div
2626
className={
2727
'w-full post-preview p-5 pb-8 bg-gray-100 text-black rounded-md transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-gray-200/50'
@@ -40,7 +40,7 @@ const PostPreview = ({
4040
<Image
4141
src={thumbnailImage || example}
4242
priority={true}
43-
alt={'dd'}
43+
alt={title}
4444
width={500}
4545
height={300}
4646
className={`object-cover bg-cover w-full h-full transition-opacity duration-300 ${

app/entities/post/write/BlogForm.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import { StaticImport } from 'next/dist/shared/lib/get-img-props';
1212
import LoadingSpinner from '@/app/entities/common/Loading/LoadingSpinner';
1313
import axios from 'axios';
1414
import useToast from '@/app/hooks/useToast';
15+
import { useBlockNavigate } from '@/app/hooks/useBlockNavigate';
1516

1617
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
1718

1819
interface BlogFormProps {
19-
postBlog: (post: PostBody) => void;
20+
postBlog: (post: PostBody) => Promise<void>;
2021
postId: string | null;
2122
}
2223

@@ -32,6 +33,8 @@ const BlogForm = ({ postBlog, postId }: BlogFormProps) => {
3233
const buttonStyle = `font-bold py-2 px-4 rounded mr-2 disabled:bg-opacity-75 `;
3334
const NICKNAME = '개발자 서정우';
3435

36+
useBlockNavigate({ title, content: content || '' });
37+
3538
useEffect(() => {
3639
if (postId) {
3740
getPostDetail();

app/hooks/useBlockNavigate.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useEffect, useState } from 'react';
2+
3+
interface PostForm {
4+
title: string;
5+
content: string;
6+
}
7+
8+
export const useBlockNavigate = (formData: PostForm, alertMessage?: string) => {
9+
const [isDirty, setIsDirty] = useState(false);
10+
11+
useEffect(() => {
12+
if (formData.title || formData.content) {
13+
setIsDirty(true);
14+
}
15+
}, [formData]);
16+
17+
useEffect(() => {
18+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
19+
if (isDirty) {
20+
const message = alertMessage || '변경사항이 적용되지 않을 수 있습니다.';
21+
e.returnValue = message;
22+
return message;
23+
}
24+
};
25+
26+
window.addEventListener('beforeunload', handleBeforeUnload);
27+
28+
return () => {
29+
window.removeEventListener('beforeunload', handleBeforeUnload);
30+
};
31+
}, [isDirty]);
32+
};

app/lib/dbConnect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ async function dbConnect(): Promise<Mongoose> {
3434
}
3535

3636
mongoose.connection.on('connected', () => {
37-
console.log('🎶 Success to connect with database');
37+
console.log('🎶 MongoDB와 연결 성공');
3838
});
3939

4040
mongoose.connection.on('error', (error: Error) => {
41-
console.error('👻 MongoDB Connect Fail!', error);
41+
console.error('👻 MongoDB 연결 실패!', error);
4242
});
4343

4444
if (!cached.promise) {

app/lib/utils/post.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Model } from 'mongoose';
2+
import type { Post } from '@/app/types/Post';
3+
export const createPostSlug = (title: string) => {
4+
return title
5+
.toLowerCase()
6+
.replace(/[^a-zA-Z0-9-]/g, '-')
7+
.replace(/-+/g, '-')
8+
.replace(/^-|-$/g, '');
9+
};
10+
11+
export const generateUniqueSlug = async (title: string, Post: Model<Post>) => {
12+
let slug = createPostSlug(title);
13+
let counter = 1;
14+
15+
let existingPost = await Post.findOne({ slug });
16+
17+
while (existingPost) {
18+
slug = `${createPostSlug(title)}-${counter}`;
19+
counter++;
20+
existingPost = await Post.findOne({ slug });
21+
}
22+
23+
return slug;
24+
};

app/migrate/generateDefaultSlug.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// migration.js
2+
import { generateUniqueSlug } from '@/app/lib/utils/post';
3+
import Post from '@/app/models/Post';
4+
import mongoose from 'mongoose';
5+
6+
// MongoDB URI를 직접 입력하거나 환경변수에서 가져옵니다
7+
const MONGODB_URI = process.env.DB_URI || '여기에_mongodb_uri를_입력하세요';
8+
async function migrateExistingPosts() {
9+
try {
10+
// MongoDB 연결
11+
console.log('MONGODB_URI:', MONGODB_URI);
12+
await mongoose.connect(MONGODB_URI);
13+
console.log('Connected to MongoDB');
14+
15+
const posts = await Post.find({ slug: { $exists: false } });
16+
console.log(`Found ${posts.length} posts without slugs`);
17+
18+
for (const post of posts) {
19+
post.slug = await generateUniqueSlug(post.title, Post);
20+
await post.save();
21+
console.log(`Updated post: ${post.title} with slug: ${post.slug}`);
22+
}
23+
24+
console.log('Migration completed');
25+
} catch (error) {
26+
console.error('Migration failed:', error);
27+
} finally {
28+
// 연결 종료
29+
await mongoose.connection.close();
30+
}
31+
}
32+
33+
migrateExistingPosts();

app/models/Post.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { Schema, model, models } from 'mongoose';
22

33
const postSchema = new Schema(
44
{
5+
slug: {
6+
type: String,
7+
required: true,
8+
unique: true,
9+
},
510
title: {
611
type: String,
712
required: true,

0 commit comments

Comments
 (0)