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
20 changes: 20 additions & 0 deletions apps/web/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Link from 'next/link';

export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<h2 className="mb-4 text-2xl font-bold text-gray-800">
어머! 없어요...
</h2>
<p className="mb-6 text-gray-600">아니 없어요 그냥...</p>
<Link
href="/"
className="inline-block rounded bg-blue-500 px-6 py-3 text-white transition hover:bg-blue-600"
>
홈으로 가기
</Link>
</div>
</div>
);
}
3 changes: 0 additions & 3 deletions apps/web/app/posts/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import Link from 'next/link';

import { PostDetail } from '@/features/post/post-detail';

// 특정 게시글 페이지 - 게시글 생성 후 넘어가지는지 확인용으로 만든 임시 페이지
export default function PostPage() {
return (
<main>
<PostDetail />
<Link href={'/'}>목록으로</Link>
</main>
);
}
23 changes: 23 additions & 0 deletions apps/web/app/posts/edit/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { notFound } from 'next/navigation';

import { getPostById } from '@/entities/post';
import { PostForm } from '@/features/post/post-editor';

interface PostEditPageProps {
params: { id: string };
}

export default async function PostEditPage({ params }: PostEditPageProps) {
const postId = (await params).id;
const post = await getPostById(postId);

if (!post) {
notFound();
}

return (
<main className="mx-auto max-w-2xl p-6">
<PostForm post={post} />
</main>
);
}
4 changes: 2 additions & 2 deletions apps/web/app/posts/write/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { PostForm } from '@/features/post/post-create';
import { PostForm } from '@/features/post/post-editor';

export default function NewPostPage() {
return (
<main>
<main className="mx-auto max-w-2xl p-6">
<PostForm />
</main>
);
Expand Down
9 changes: 7 additions & 2 deletions apps/web/entities/post/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export { createPost, getPostById, getPosts } from './api/post-api';
export { createPost, getPostById, getPosts, updatePost } from './api/post-api';
export { ERROR_MESSAGES } from './lib/error-messages';
export { validateFormField } from './lib/form-validation';
export {
extractFormData,
getValidatedField,
getValidatedFields,
validateFormField,
} from './lib/form-validation';
export type {
CreatePostDTO,
GetPostsCursor,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/entities/post/lib/error-messages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export const ERROR_MESSAGES = {
INVALID_TYPE: '유효하지 않은 입력 형식입니다.',
INVALID_ID: '유효하지 않은 게시글 입니다.',
REQUIRED_TITLE: '제목을 입력해주세요',
REQUIRED_CONTENT: '내용을 입력해주세요',
REQUIRED_AUTHOR: '작성자를 입력해주세요',
SAVE_FAILED: '게시글 저장에 실패했습니다:',
UPDATE_FAILED: '게시글 수정에 실패했습니다.',
} as const;
43 changes: 41 additions & 2 deletions apps/web/entities/post/lib/form-validation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { ERROR_MESSAGES } from '@/entities/post/';
import { CreatePostDTO, ERROR_MESSAGES, UpdatePostDTO } from '@/entities/post/';

export const extractFormData = <T extends Record<string, unknown>>(
formData: FormData,
fields: Extract<keyof T, string>[]
): Partial<Record<Extract<keyof T, string>, string | null>> => {
return fields.reduce(
(acc, field) => {
acc[field] = formData.get(field) as string | null;
return acc;
},
{} as Partial<Record<Extract<keyof T, string>, string | null>>
);
};

export const validateFormField = (
value: FormDataEntryValue | null,
fieldName: 'title' | 'content' | 'author'
fieldName: keyof (CreatePostDTO & UpdatePostDTO)
): string => {
if (typeof value !== 'string') {
throw new Error(ERROR_MESSAGES.INVALID_TYPE);
Expand All @@ -16,3 +29,29 @@ export const validateFormField = (

return value;
};

export const getValidatedField = <
K extends keyof (CreatePostDTO & UpdatePostDTO),
>(
formData: FormData,
field: K
) => {
const value = formData.get(field);
return value ? validateFormField(value, field) : undefined;
};

export function getValidatedFields<T extends CreatePostDTO | UpdatePostDTO>(
rawData: Partial<Record<keyof T, string | null>>
): T {
return Object.fromEntries(
Object.entries(rawData)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [
key,
validateFormField(
value ?? null,
key as keyof (CreatePostDTO & UpdatePostDTO)
),
])
) as T;
}
2 changes: 1 addition & 1 deletion apps/web/features/post/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { createPostAction } from './post-actions';
export { createPostAction, updatePostAction } from './post-actions';
53 changes: 40 additions & 13 deletions apps/web/features/post/actions/post-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@ import {
createPost,
CreatePostDTO,
ERROR_MESSAGES,
validateFormField,
extractFormData,
getValidatedFields,
updatePost,
UpdatePostDTO,
} from '@/entities/post';

// 게시글 작성 서버 액션
export async function createPostAction(_: unknown, formData: FormData) {
let postId: string | null = null;

try {
const title = validateFormField(formData.get('title'), 'title');
const content = validateFormField(formData.get('content'), 'content');
const author = validateFormField(formData.get('author'), 'author');
const rawData = extractFormData<CreatePostDTO>(formData, [
'title',
'content',
'author',
]);
const validatedData: CreatePostDTO = getValidatedFields(rawData);

const postData: CreatePostDTO = { title, content, author };

postId = (await createPost(postData)).id;
postId = (await createPost(validatedData)).id;

revalidatePath('/');
} catch (error) {
Expand All @@ -32,13 +37,35 @@ export async function createPostAction(_: unknown, formData: FormData) {
: `${ERROR_MESSAGES.SAVE_FAILED} 알 수 없는 오류`,
};
}
redirect(`/posts/${postId}`);
}

if (postId) {
redirect(`/posts/${postId}`);
// 게시글 수정 서버 액션
export async function updatePostAction(_: unknown, formData: FormData) {
const postId = formData.get('postId') as string;
if (!postId) {
return { status: false, error: ERROR_MESSAGES.INVALID_ID };
}

return {
status: false,
error: '처리 중 예상치 못한 문제가 발생했습니다.',
};
try {
const rawData = extractFormData<UpdatePostDTO>(formData, [
'title',
'content',
'author',
]);
const validatedData: UpdatePostDTO = getValidatedFields(rawData);

await updatePost(postId, validatedData);

revalidatePath('/');
} catch (error) {
return {
status: false,
error:
error instanceof Error
? `${ERROR_MESSAGES.UPDATE_FAILED} ${error.message}`
: `${ERROR_MESSAGES.UPDATE_FAILED} 알 수 없는 오류`,
};
}
redirect(`/posts/${postId}`);
}
40 changes: 0 additions & 40 deletions apps/web/features/post/post-create/ui/post-form.tsx

This file was deleted.

35 changes: 25 additions & 10 deletions apps/web/features/post/post-detail/ui/post-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useParams } from 'next/navigation';
import { Button } from '@workspace/ui/components/button';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

import { getPostById } from '@/entities/post';
Expand All @@ -10,8 +11,9 @@ import { formatToLocaleDate } from '@/shared/lib';
// 특정 게시글 페이지 - 게시글 생성 후 넘어가지는지 확인용으로 만든 임시 컴포넌트
export function PostDetail() {
const { id } = useParams();
const router = useRouter();
const [post, setPost] = useState<Post | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
Expand All @@ -32,16 +34,29 @@ export function PostDetail() {
fetchPost();
}, [id]);

if (isLoading) return <p>로딩 중...</p>;
if (error) return <p>{error}</p>;
if (!post) return <p>게시글을 찾을 수 없습니다.</p>;
if (isLoading) return <p className="text-center text-gray-500">로딩 중...</p>;
if (error) return <p className="text-center text-red-500">{error}</p>;
if (!post)
return (
<p className="text-center text-gray-500">게시글을 찾을 수 없습니다.</p>
);

return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<p>작성자: {post.author}</p>
<p>작성일: {formatToLocaleDate(post.createdAt)}</p>
<div className="mx-auto max-w-2xl space-y-4 rounded-lg bg-white p-6 shadow-md">
<h1 className="text-2xl font-bold text-gray-900">{post.title}</h1>
<p className="whitespace-pre-line text-gray-700">{post.content}</p>
<div className="text-sm text-gray-500">
<p>작성자: {post.author}</p>
<p>작성일: {formatToLocaleDate(post.createdAt)}</p>
</div>
<div className="mt-4 flex justify-between">
<Button variant="outline" onClick={() => router.push('/')}>
목록으로
</Button>
<Button onClick={() => router.push(`/posts/edit/${post.id}`)}>
수정하기
</Button>
</div>
</div>
);
}
73 changes: 73 additions & 0 deletions apps/web/features/post/post-editor/ui/post-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';

import { Button } from '@workspace/ui/components/button';
import { useRouter } from 'next/navigation';
import { useActionState } from 'react';

import { Post } from '@/entities/post';
import { createPostAction, updatePostAction } from '@/features/post/actions';
import { TextField } from '@/shared/ui';

interface PostFormProps {
post?: Post;
}

export function PostForm({ post }: PostFormProps) {
const router = useRouter();
const isEditMode = Boolean(post);

const [actionResult, formAction, isPending] = useActionState(
isEditMode ? updatePostAction : createPostAction,
null
);

return (
<form action={formAction}>
<h1 className="mb-4 text-xl font-bold">
{isEditMode ? '게시글 수정' : '새 게시글 작성'}
</h1>

{post?.id && <input type="hidden" name="postId" value={post.id} />}

<TextField
name="title"
label="제목"
required
disabled={isPending}
defaultValue={post?.title || ''}
/>
<TextField
name="content"
label="내용"
required
disabled={isPending}
isTextArea
defaultValue={post?.content || ''}
/>
<TextField
name="author"
label="작성자"
required
disabled={isPending}
readOnly={!!post}
defaultValue={post?.author || ''}
/>

{actionResult?.status === false && (
<p className="mt-2 text-sm text-red-500">{actionResult.error}</p>
)}

<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isPending}
>
취소
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '저장 중...' : '작성하기'}
</Button>
</form>
);
}