diff --git a/.husky/pre-commit b/.husky/pre-commit index e9a388c..a5a29d9 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -pnpm lint-staged \ No newline at end of file +pnpm lint-staged diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 7e42423..686ae0a 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,31 +1 @@ -import '@workspace/ui/globals.css'; - -import { Geist, Geist_Mono } from 'next/font/google'; - -import { Providers } from '@/components/providers'; - -const fontSans = Geist({ - subsets: ['latin'], - variable: '--font-sans', -}); - -const fontMono = Geist_Mono({ - subsets: ['latin'], - variable: '--font-mono', -}); - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} +export { RootLayout as default } from '@/src/app/layout'; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 58ee290..09e16c7 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,15 +1 @@ -export default function Page() { - return ( -
-
-

- Hello World -

- {/* */} -
- Test -
-
-
- ); -} +export { HomePage as default } from '@/src/app/main'; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..e75286f --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,27 @@ +import '@workspace/ui/globals.css'; + +import { Geist, Geist_Mono } from 'next/font/google'; + +import { Providers } from '@/src/app/providers/providers'; + +const fontSans = Geist({ + subsets: ['latin'], + variable: '--font-sans', +}); + +const fontMono = Geist_Mono({ + subsets: ['latin'], + variable: '--font-mono', +}); + +export function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/main/index.tsx b/apps/web/src/app/main/index.tsx new file mode 100644 index 0000000..365851b --- /dev/null +++ b/apps/web/src/app/main/index.tsx @@ -0,0 +1,12 @@ +import { ReplyForm } from '@/src/feature/reply/ui/reply-form'; +import { ReplyList } from '@/src/feature/reply/ui/reply-list'; +export function HomePage() { + return ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/providers/providers.tsx b/apps/web/src/app/providers/providers.tsx new file mode 100644 index 0000000..bb5bcea --- /dev/null +++ b/apps/web/src/app/providers/providers.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import * as React from 'react'; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/entity/reply/model/reply-type.ts b/apps/web/src/entity/reply/model/reply-type.ts new file mode 100644 index 0000000..8fdce25 --- /dev/null +++ b/apps/web/src/entity/reply/model/reply-type.ts @@ -0,0 +1,22 @@ +export interface ReplyType { + id: string; + postId: string; + content: string; + author: string; + createdAt: string; + updatedAt: string; + parentCommentId: string; +} +export interface CreateReplyDTO { + content: string; + author: string; +} +export interface UpdateReplyDTO { + content: string; + author: string; +} +export interface ReplyCursorPaginationResponse { + data: ReplyType[]; + nextCursor: string; // default : true + hasMore: boolean; +} diff --git a/apps/web/src/feature/comment/ui/comment.tsx b/apps/web/src/feature/comment/ui/comment.tsx new file mode 100644 index 0000000..85d32e8 --- /dev/null +++ b/apps/web/src/feature/comment/ui/comment.tsx @@ -0,0 +1,38 @@ +import { Button } from '@workspace/ui/components/button'; +import Image from 'next/image'; + +import { profile } from '@/src/shared/assets'; +import { formatTimeAgo } from '@/src/shared/lib'; +export function Comment({ + author, + content, + createdAt, + updatedAt, + onEdit, +}: { + author: string; + content: string; + createdAt: string; + updatedAt: string; + onEdit: () => void; +}) { + return ( +
+
+ profile +
+
+
+

{author}

+ + {formatTimeAgo(updatedAt ? updatedAt : createdAt)} + +
+

{content}

+
+ +
+
+
+ ); +} diff --git a/apps/web/src/feature/reply/api/reply-api.ts b/apps/web/src/feature/reply/api/reply-api.ts new file mode 100644 index 0000000..f44cf97 --- /dev/null +++ b/apps/web/src/feature/reply/api/reply-api.ts @@ -0,0 +1,55 @@ +import { fetchWrapper } from '@/src/shared/api'; + +import { + CreateReplyDTO, + ReplyCursorPaginationResponse, + UpdateReplyDTO, +} from '../../../entity/reply/model/reply-type'; + +export const getReply = ( + postId: string, + commentId: string, + cursor?: string, + limit?: number +): Promise => { + const queryParams = new URLSearchParams(); + if (cursor) queryParams.append('cursor', cursor); + if (limit) queryParams.append('limit', limit.toString()); + + return fetchWrapper.get( + `posts/${postId}/comments/${commentId}/replies?${queryParams.toString()}` + ); +}; + +export const createReply = ( + postId: string, + commentId: string, + content: CreateReplyDTO +): Promise => { + return fetchWrapper.post( + `/posts/${postId}/comments/${commentId}/replies`, + content + ); +}; + +export const deleteReply = ( + postId: string, + commentId: string, + replyId: string +): Promise => { + return fetchWrapper.delete( + `/posts/${postId}/comments/${commentId}/replies/${replyId}` + ); +}; + +export const updateReply = ( + postId: string, + commentId: string, + replyId: string, + content: UpdateReplyDTO +): Promise => { + return fetchWrapper.put( + `/posts/${postId}/comments/${commentId}/replies/${replyId}`, + content + ); +}; diff --git a/apps/web/src/feature/reply/model/reply-add.ts b/apps/web/src/feature/reply/model/reply-add.ts new file mode 100644 index 0000000..4c900f4 --- /dev/null +++ b/apps/web/src/feature/reply/model/reply-add.ts @@ -0,0 +1,20 @@ +'use server'; + +import { createReply } from '@/src/feature/reply/api/reply-api'; +type State = { + message?: string | null; +}; + +export async function replyAdd(prevState: State, queryData: FormData) { + const postId = queryData.get('postId') as string; + const commentId = queryData.get('commentId') as string; + const content = queryData.get('content') as string; + // const author = queryData.get("author") as string; + const author = 'guest'; + if (!content) { + return { message: '모든 빈 칸을 입력해주세요' }; + } + + await createReply(postId, commentId, { content, author }); + return { message: '댓글 작성 완료' }; +} diff --git a/apps/web/src/feature/reply/model/reply-delete.ts b/apps/web/src/feature/reply/model/reply-delete.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/feature/reply/model/reply-fetch.ts b/apps/web/src/feature/reply/model/reply-fetch.ts new file mode 100644 index 0000000..4a8ebe2 --- /dev/null +++ b/apps/web/src/feature/reply/model/reply-fetch.ts @@ -0,0 +1,38 @@ +'use clients'; +import { useCallback, useState } from 'react'; + +import type { ReplyType } from '@/src/entity/reply/model/reply-type'; +import { getReply } from '@/src/feature/reply/api/reply-api'; +import { useInfiniteScroll } from '@/src/shared/lib/useInfiniteScroll'; + +export function useReplyFetch() { + const [data, setData] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [nextCursor, setNextCursor] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + + const fetchReplies = useCallback(async () => { + if (!hasMore || isLoading) return; + + setIsLoading(true); + try { + const response = await getReply( + '17358013402371', + '1738039148628', + nextCursor, + 10 + ); + setData((prev) => [...prev, ...response.data]); + setHasMore(response.hasMore); + setNextCursor(response.nextCursor); + } catch (error) { + throw new Error(error as string); + } finally { + setIsLoading(false); + } + }, [nextCursor, hasMore, isLoading]); + + const { targetRef } = useInfiniteScroll(fetchReplies, { threshold: 1 }); + + return { data, targetRef, isLoading }; +} diff --git a/apps/web/src/feature/reply/model/reply-modify.ts b/apps/web/src/feature/reply/model/reply-modify.ts new file mode 100644 index 0000000..4e05e4b --- /dev/null +++ b/apps/web/src/feature/reply/model/reply-modify.ts @@ -0,0 +1,27 @@ +'use server'; + +import { updateReply } from '../api/reply-api'; + +type State = { + message?: string | null; +}; + +export async function replymodify(prevState: State, queryData: FormData) { + const postId = queryData.get('postId') as string; + const commentId = queryData.get('commentId') as string; + const replyId = queryData.get('replyId') as string; + const content = queryData.get('content') as string; + const author = 'guest'; + + if (!content.trim()) { + return { message: '모든 빈 칸을 입력해주세요' }; + } + + try { + await updateReply(postId, commentId, replyId, { content, author }); + return { message: '수정 완료' }; + } catch (error) { + console.error('댓글 수정 오류:', error); + return { message: '수정 실패' }; + } +} diff --git a/apps/web/src/feature/reply/ui/reply-form.tsx b/apps/web/src/feature/reply/ui/reply-form.tsx new file mode 100644 index 0000000..8194ab6 --- /dev/null +++ b/apps/web/src/feature/reply/ui/reply-form.tsx @@ -0,0 +1,28 @@ +'use client'; +import { Button } from '@workspace/ui/components/button'; +import { useSearchParams } from 'next/navigation'; +import { useActionState } from 'react'; + +import { replyAdd } from '@/src/feature/reply/model/reply-add'; + +export function ReplyForm() { + const [state, dispatch] = useActionState(replyAdd, { message: '' }, '/'); + const searchParams = useSearchParams(); + const parentId = searchParams.get('postId'); + const postId = searchParams.get('parentCommentId'); + + return ( +
+