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 (
+
+
+
+
+
+
+
{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 (
+
+ );
+}
diff --git a/apps/web/src/feature/reply/ui/reply-list.tsx b/apps/web/src/feature/reply/ui/reply-list.tsx
new file mode 100644
index 0000000..451e9c1
--- /dev/null
+++ b/apps/web/src/feature/reply/ui/reply-list.tsx
@@ -0,0 +1,52 @@
+'use client';
+import { useState } from 'react';
+
+import { Comment } from '@/src/feature/comment/ui/comment';
+import { useReplyFetch } from '@/src/feature/reply/model/reply-fetch';
+
+import { ReplyModifyForm } from './reply-modify-form';
+
+export function ReplyList() {
+ const { data, targetRef, isLoading } = useReplyFetch();
+ const [editId, setEditId] = useState('');
+
+ const handleEdit = (id: string) => {
+ setEditId(id);
+ };
+
+ return (
+
+ {data.map(
+ ({
+ id,
+ author,
+ content,
+ createdAt,
+ updatedAt,
+ postId,
+ parentCommentId,
+ }) => (
+
+ {editId === id ? (
+ handleEdit('')}
+ />
+ ) : (
+ handleEdit(id)}
+ />
+ )}
+
+ )
+ )}
+ {isLoading ?
Loading...
:
}
+
+ );
+}
diff --git a/apps/web/src/feature/reply/ui/reply-modify-form.tsx b/apps/web/src/feature/reply/ui/reply-modify-form.tsx
new file mode 100644
index 0000000..154cc5b
--- /dev/null
+++ b/apps/web/src/feature/reply/ui/reply-modify-form.tsx
@@ -0,0 +1,49 @@
+'use client';
+import { Button } from '@workspace/ui/components/button';
+import { useActionState, useTransition } from 'react';
+
+import { replymodify } from '@/src/feature/reply/model/reply-modify';
+
+export function ReplyModifyForm({
+ id,
+ postId,
+ parentCommentId,
+ onEdit,
+}: {
+ id: string;
+ postId: string;
+ parentCommentId: string;
+ onEdit: () => void;
+}) {
+ const [state, dispatch] = useActionState(replymodify, { message: '' });
+ const [isPending, startTransition] = useTransition();
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target as HTMLFormElement);
+
+ startTransition(() => {
+ dispatch(formData);
+ onEdit();
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/shared/api/fetch-wrapper.ts b/apps/web/src/shared/api/fetch-wrapper.ts
new file mode 100644
index 0000000..52a15ac
--- /dev/null
+++ b/apps/web/src/shared/api/fetch-wrapper.ts
@@ -0,0 +1,55 @@
+export const fetchWrapper = {
+ get,
+ post,
+ put,
+ delete: _delete,
+};
+const BASE_URL = 'http://localhost:4000/api';
+
+async function get(url: string): Promise {
+ const requestOptions = {
+ method: 'GET',
+ };
+
+ return fetch(`${BASE_URL}/${url}`, requestOptions).then(handleResponse);
+}
+
+async function post(url: string, body: T): Promise {
+ const requestOptions = {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ };
+ return fetch(`${BASE_URL}/${url}`, requestOptions).then(handleResponse);
+}
+
+function put(url: string, body: T): Promise {
+ const requestOptions = {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ };
+ return fetch(`${BASE_URL}/${url}`, requestOptions).then(handleResponse);
+}
+
+function _delete(url: string): Promise {
+ const requestOptions = {
+ method: 'DELETE',
+ };
+ return fetch(`${BASE_URL}/${url}`, requestOptions).then(handleResponse);
+}
+
+function handleResponse(response: Response) {
+ return response.text().then((text) => {
+ try {
+ const data = text ? JSON.parse(text) : {};
+ if (!response.ok) {
+ const error = (data && data.message) || response.statusText;
+ return Promise.reject(error);
+ }
+ return data;
+ } catch {
+ return Promise.reject('JSON 파싱 오류');
+ }
+ });
+}
diff --git a/apps/web/src/shared/api/index.ts b/apps/web/src/shared/api/index.ts
new file mode 100644
index 0000000..f69bf4a
--- /dev/null
+++ b/apps/web/src/shared/api/index.ts
@@ -0,0 +1 @@
+export { fetchWrapper } from '@/src/shared/api/fetch-wrapper';
diff --git a/apps/web/src/shared/assets/index.ts b/apps/web/src/shared/assets/index.ts
new file mode 100644
index 0000000..7b59209
--- /dev/null
+++ b/apps/web/src/shared/assets/index.ts
@@ -0,0 +1,3 @@
+import profile from './profile.png';
+
+export { profile };
diff --git a/apps/web/src/shared/assets/profile.png b/apps/web/src/shared/assets/profile.png
new file mode 100644
index 0000000..ce7a2f5
Binary files /dev/null and b/apps/web/src/shared/assets/profile.png differ
diff --git a/apps/web/src/shared/favicon.ico b/apps/web/src/shared/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/apps/web/src/shared/favicon.ico differ
diff --git a/apps/web/src/shared/lib/formatTimeAgo.ts b/apps/web/src/shared/lib/formatTimeAgo.ts
new file mode 100644
index 0000000..b25aa5a
--- /dev/null
+++ b/apps/web/src/shared/lib/formatTimeAgo.ts
@@ -0,0 +1,17 @@
+export const formatTimeAgo = (date: string) => {
+ const now = Date.now();
+ const past = new Date(date).getTime();
+ const diffInMs = now - past;
+
+ const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
+ const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
+ const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
+
+ if (diffInMinutes < 1) return '방금 전';
+ if (diffInMinutes < 60) return `${diffInMinutes}분 전`;
+ if (diffInHours < 24) return `${diffInHours}시간 전`;
+ if (diffInDays < 7) return `${diffInDays}일 전`;
+ if (diffInDays < 30) return `${Math.floor(diffInDays / 7)}주 전`;
+ if (diffInDays < 365) return `${Math.floor(diffInDays / 30)}달 전`;
+ return `${Math.floor(diffInDays / 365)}년 전`;
+};
diff --git a/apps/web/src/shared/lib/index.ts b/apps/web/src/shared/lib/index.ts
new file mode 100644
index 0000000..b0ca5d9
--- /dev/null
+++ b/apps/web/src/shared/lib/index.ts
@@ -0,0 +1,2 @@
+export { formatTimeAgo } from '@/src/shared/lib/formatTimeAgo';
+export { useInfiniteScroll } from '@/src/shared/lib/useInfiniteScroll';
diff --git a/apps/web/src/shared/lib/useInfiniteScroll.tsx b/apps/web/src/shared/lib/useInfiniteScroll.tsx
new file mode 100644
index 0000000..920ffd9
--- /dev/null
+++ b/apps/web/src/shared/lib/useInfiniteScroll.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useRef } from 'react';
+function useCustomRef() {
+ return useRef(null);
+}
+export function useInfiniteScroll(
+ fetchData: () => Promise,
+ {
+ threshold,
+ root,
+ rootMargin,
+ }: { threshold: number; root?: Element | Document; rootMargin?: string }
+) {
+ const targetRef = useCustomRef();
+
+ useEffect(() => {
+ if (!targetRef.current) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ fetchData();
+ }
+ },
+ { threshold: threshold, root: root, rootMargin: rootMargin }
+ );
+
+ const currentTarget = targetRef.current;
+ if (currentTarget) {
+ observer.observe(currentTarget);
+ }
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [fetchData, threshold, root, rootMargin, targetRef]);
+
+ return { targetRef };
+}
diff --git a/packages/ui/tailwind.config.ts b/packages/ui/tailwind.config.ts
index 8096a01..e90262e 100644
--- a/packages/ui/tailwind.config.ts
+++ b/packages/ui/tailwind.config.ts
@@ -5,6 +5,7 @@ const config = {
darkMode: ['class'],
content: [
'app/**/*.{ts,tsx}',
+ 'src/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',
'../../packages/ui/src/components/**/*.{ts,tsx}',
],