diff --git a/src/components/Article/ArticleList.tsx b/src/components/Article/ArticleList.tsx
new file mode 100644
index 00000000..9311664d
--- /dev/null
+++ b/src/components/Article/ArticleList.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { orderByType, POST_OPTIONS, postByType } from "@/constants/product.constants";
+import { useAuth } from "@/contexts/AuthContext";
+import { useBreakpoint } from "@/hooks/useBreakpoint";
+import { usePushQueryToURL } from "@/hooks/useItemQuery";
+import { useConfirmModal } from "@/hooks/useModal";
+import { useRouter, useSearchParams } from "next/navigation";
+import Container from "../layout/Container";
+import Title from "../ui/Title";
+import SearchBox from "../ui/form/SearchBox";
+import Button from "../ui/Button";
+import SelectBox from "../ui/SelectBox";
+import LoadingBox from "../ui/LoadingBox";
+import EmptyBox from "../ui/EmptyBox";
+import ConfirmModal from "../ui/ConfirmModal";
+import ArticleListItem from "./ArticleListItem";
+import { PostListQuery, useInfiniteArticles } from "@/hooks/useArticles";
+
+
+export function ArticleList() {
+
+ const { user } = useAuth();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const breakpoint = useBreakpoint();
+
+
+ const query: PostListQuery = {
+ page: Number(searchParams.get("page") ?? 1),
+ pageSize: Number(searchParams.get("pageSize") ?? 10),
+ orderBy: (searchParams.get("orderBy") ?? 'recent')as postByType,
+ keyword: searchParams.get("keyword") ?? '',
+ };
+
+ const pushQueryToURL = usePushQueryToURL();
+ const { data, handleLoadMore, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteArticles(query);
+ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal();
+
+ // SelectBox handle
+ const handleSelectBoxClick = (value: string) => {
+ const next = { ...query, orderBy: value as orderByType, page: 1 };
+ pushQueryToURL(next);
+ };
+
+ // Keyword handle
+ const handleKeywordChange = (keyword: string) => {
+ const next = { ...query, keyword,page: 1};
+ pushQueryToURL(next);
+ };
+
+ const handleApplyClick = () => {
+ if(!user) {
+ openConfirmModal('로그인 후 이용 가능합니다.');
+ return;
+ }
+ router.push('addboard');
+ };
+ return (
+ <>
+
+
+
+
+
+ handleKeywordChange(keyword)} />
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ data ? (
+
+
+ {data?.pages.flatMap((page) => (
+ page.list.map((article) => (
+
+ ))
+ ))}
+
+ {isFetchingNextPage &&
}
+ {hasNextPage && (
+
+
+
+ )}
+
+ ) : (
+
+ )
+ )}
+
+
+ >
+ );
+}
+
diff --git a/src/components/Article/ArticleListItem.tsx b/src/components/Article/ArticleListItem.tsx
new file mode 100644
index 00000000..0421811d
--- /dev/null
+++ b/src/components/Article/ArticleListItem.tsx
@@ -0,0 +1,62 @@
+import React, { useEffect } from 'react';
+import LikeButton from '../ui/LikeButton';
+import UserInfo from '../ui/UserInfo';
+import { formatDate } from '@/utils/date';
+import { FallbackImage } from '../FallbackImage/FallbackImage';
+import { PostItem, useToggleArticlesFavorite } from '@/hooks/useArticles';
+import ConfirmModal from '../ui/ConfirmModal';
+import { useConfirmModal } from '@/hooks/useModal';
+import { useRouter } from 'next/navigation';
+
+interface ArticleListItemProps {
+ postItem: PostItem
+}
+function ArticleListItem({ postItem }: ArticleListItemProps) {
+ const router = useRouter();
+ const href = `boards/${postItem.id}`;
+ useEffect(() => {
+ router.prefetch(href);
+ }, [href, router]);
+
+ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal();
+ // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00'
+ const createdAtString = formatDate(postItem.createdAt);
+ // console.log(createdAtString);
+ const { mutate: toggleFavorite } = useToggleArticlesFavorite(openConfirmModal, {
+ onSuccess: (data) => {
+ openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다");
+ },
+ });
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export default ArticleListItem;
\ No newline at end of file
diff --git a/src/components/Article/BestArticleItem.tsx b/src/components/Article/BestArticleItem.tsx
new file mode 100644
index 00000000..eacd19d9
--- /dev/null
+++ b/src/components/Article/BestArticleItem.tsx
@@ -0,0 +1,72 @@
+import { PostItem, useToggleArticlesFavorite } from "@/hooks/useArticles";
+import { useConfirmModal } from "@/hooks/useModal";
+import { formatDate } from "@/utils/date";
+import { FallbackImage } from "../FallbackImage/FallbackImage";
+import LikeButton from "../ui/LikeButton";
+import ConfirmModal from "../ui/ConfirmModal";
+import BestBadge from "../ui/BestBadge";
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+
+interface ArticleListItemProps {
+ postItem: PostItem
+}
+function BestArticleItem({ postItem }: ArticleListItemProps) {
+ const router = useRouter();
+ const href = `boards/${postItem.id}`;
+
+ useEffect(() => {
+ router.prefetch(href);
+ }, [href, router]);
+
+ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal();
+ // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00'
+ const createdAtString = formatDate(postItem.createdAt);
+ // console.log(createdAtString);
+ const { mutate: toggleFavorite } = useToggleArticlesFavorite(openConfirmModal, {
+ onSuccess: (data) => {
+ openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다");
+ },
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {postItem.writer.nickname}
+
+
+
+ {createdAtString}
+
+
+
+
+
+ >
+ );
+}
+
+export default BestArticleItem;
diff --git a/src/components/Article/BestArticleList.tsx b/src/components/Article/BestArticleList.tsx
new file mode 100644
index 00000000..37e1177d
--- /dev/null
+++ b/src/components/Article/BestArticleList.tsx
@@ -0,0 +1,54 @@
+'use client';
+import { BEST_POST_ITEMS, BEST_VISIBLE_ITEMS } from "@/constants/product.constants";
+import { useBreakpoint } from "@/hooks/useBreakpoint";
+import { useEffect, useState } from "react";
+import Container from "../layout/Container";
+import LoadingBox from "../ui/LoadingBox";
+import EmptyBox from "../ui/EmptyBox";
+import { PostListQuery, useArticlesList } from "@/hooks/useArticles";
+import BestArticleItem from "./BestArticleItem";
+
+
+function BestArticleList() {
+ const breakpoint = useBreakpoint();
+ const INITIAL_QUERY: PostListQuery = {
+ page: 1,
+ pageSize: BEST_POST_ITEMS.length[breakpoint],
+ orderBy: 'like',
+ keyword: '',
+ };
+ const [query, setQuery] = useState(INITIAL_QUERY);
+
+ const { data , isLoading } = useArticlesList(query);
+ // 페이지 반응형 달라질때마다 pageSize 수정
+ useEffect(() => {
+ setQuery((prev: typeof INITIAL_QUERY) => ({
+ ...prev,
+ pageSize: BEST_POST_ITEMS.length[breakpoint],
+ }));
+ }, [breakpoint]);
+
+ return (
+
+
+ {isLoading ? (
+
+ ) : (
+ data ? (
+
+
+ {data.list.map((article) => (
+
+ ))
+ }
+
+
+ ) : (
+
+ )
+ )}
+
+ );
+}
+
+export default BestArticleList;
diff --git a/src/components/Article/BoardsClient.tsx b/src/components/Article/BoardsClient.tsx
new file mode 100644
index 00000000..90ea99d6
--- /dev/null
+++ b/src/components/Article/BoardsClient.tsx
@@ -0,0 +1,21 @@
+'use client'
+import React from 'react';
+import Container from 'components/layout/Container';
+import Title from 'components/ui/Title';
+import BestArticleList from '@/components/Article/BestArticleList';
+import { ArticleList } from '@/components/Article/ArticleList';
+
+function BoardsClient() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+export default BoardsClient;
+
diff --git a/src/components/Article/comment/CommentForm.tsx b/src/components/Article/comment/CommentForm.tsx
new file mode 100644
index 00000000..14de57c4
--- /dev/null
+++ b/src/components/Article/comment/CommentForm.tsx
@@ -0,0 +1,45 @@
+
+import Button from 'components/ui/Button';
+import { TextAreaBox } from '@/components/ui/form/InputBox';
+import React, { useState } from 'react';
+import { useConfirmModal, useModal } from '@/hooks/useModal';
+import ConfirmModal from '@/components/ui/ConfirmModal';
+import { usePostArticleComment } from '@/hooks/useArticles';
+import { useAuth } from '@/contexts/AuthContext';
+
+type CommentFormProps = {
+ articleId: number;
+};
+
+function CommentForm({articleId}: CommentFormProps) {
+ const { user } = useAuth();
+ const [requestCommentValue, setRequestCommentValue] = useState
('');
+ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal();
+
+ const { mutate: postComment } = usePostArticleComment(articleId, openConfirmModal);
+
+ const handleClick = () => {
+ if(!user) {
+ openConfirmModal('로그인 후 이용 가능합니다.');
+ return;
+ }
+ postComment(requestCommentValue);
+ setRequestCommentValue('');
+ };
+
+ return (
+
+
댓글달기
+
) => setRequestCommentValue(e.target.value)}
+ />
+
+
+
+
+
+ );
+}
+export default CommentForm;
\ No newline at end of file
diff --git a/src/components/Article/comment/CommentItem.tsx b/src/components/Article/comment/CommentItem.tsx
new file mode 100644
index 00000000..73f9eb2a
--- /dev/null
+++ b/src/components/Article/comment/CommentItem.tsx
@@ -0,0 +1,111 @@
+
+import React, { useState } from 'react';
+import { formatDate } from 'utils/date';
+import UserInfo from 'components/ui/UserInfo';
+import clsx from 'clsx';
+import { TextAreaBox } from '@/components/ui/form/InputBox';
+import Button from 'components/ui/Button';
+import DropdownMenu from 'components/ui/DropdownMenu';
+import Modal from '@/components/ui/Modal';
+import { useConfirmModal, useModal } from '@/hooks/useModal';
+import ConfirmModal from '@/components/ui/ConfirmModal';
+import { useAuth } from '@/contexts/AuthContext';
+import { useDeleteArticleComment, usePatchArticleComment } from '@/hooks/useArticles';
+import { CommentItemUnit } from '@/hooks/useItems';
+
+interface CommentItemProps {
+ articleId: number;
+ commentItem: CommentItemUnit;
+}
+
+function CommentItem({articleId,commentItem}:CommentItemProps) {
+ const {
+ id:commentId,
+ content,
+ updatedAt,
+ } = commentItem;
+
+ const createdAtString = formatDate(updatedAt);
+
+ const [editMode, setEditMode] = useState(false);
+ const [requestCommentValue, setRequestCommentValue] = useState(content);
+
+ const { isModalOpen, modalMessage, openModal, closeModal } = useModal();
+ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal();
+
+ const { mutate: deleteProduct } = useDeleteArticleComment(articleId,openConfirmModal);
+ const { mutate: patchComment } = usePatchArticleComment(articleId,openConfirmModal);
+ const { user } = useAuth();
+
+ const handleOpenModal = () =>{
+ if(!user) {
+ openConfirmModal('로그인 후 이용 가능합니다.');
+ return;
+ }
+ if(commentItem.writer.id !== user?.id) {
+ openConfirmModal('본인의 댓글만 삭제할 수 있습니다.');
+ return;
+ }
+ openModal('정말 삭제하시겠습니까?');
+ };
+ const handleOpenEdit = () =>{
+ if(!user) {
+ openConfirmModal('로그인 후 이용 가능합니다.');
+ return;
+ }
+ if(commentItem.writer.id !== user?.id) {
+ openConfirmModal('본인의 댓글만 수정할 수 있습니다.');
+ return;
+ }
+ setEditMode(true);
+ };
+
+ const handleConfirmDelete = () => {
+ deleteProduct(commentId);
+ closeConfirmModal();
+ };
+ const handleUpdate = () =>{
+ patchComment({ commentId, requestCommentValue });
+ setEditMode(false)
+ };
+
+ const dropdownActions = [
+ {
+ label: '삭제하기',
+ onClick: handleOpenModal,
+ },
+ {
+ label: '수정하기',
+ onClick:handleOpenEdit,
+ },
+ ];
+
+
+ return (
+
+ {editMode === true ? (
+
+
) => setRequestCommentValue(target.value)}
+ />
+
+
+
+
+
+ ):(
+
+ {requestCommentValue}
+
+
+ )}
+
+
+
+
+ );
+}
+export default CommentItem;
\ No newline at end of file
diff --git a/src/components/Article/comment/CommentList.tsx b/src/components/Article/comment/CommentList.tsx
new file mode 100644
index 00000000..712d822b
--- /dev/null
+++ b/src/components/Article/comment/CommentList.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import CommentItem from './CommentItem';
+import LoadingBox from '@/components/ui/LoadingBox';
+import EmptyBox from '@/components/ui/EmptyBox';
+import { useInfiniteArticleCommentsWithObserver } from '@/hooks/useArticles';
+import { replyEmptyImg } from '@/lib/imageAssets';
+
+
+interface CommentListProps {
+ articleId: number;
+ className?: string;
+}
+
+function CommentList({ articleId,className, ...rest }: CommentListProps) {
+
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ loadMoreRef }
+ = useInfiniteArticleCommentsWithObserver(articleId);
+
+ return (
+ <>
+ {isLoading ? : data?.pages?.[0].list.length ? (
+
+ {data?.pages.map((page, i) => (
+
+ {page.list.map((comment) => (
+
+
+
+ ))}
+
+ ))}
+
+ {isFetchingNextPage &&
로딩 중...
}
+
+ ):(
+
+ )
+ }
+ >
+
+ );
+}
+export default CommentList;
diff --git a/src/components/Article/comment/CommentSection.tsx b/src/components/Article/comment/CommentSection.tsx
new file mode 100644
index 00000000..4a863b85
--- /dev/null
+++ b/src/components/Article/comment/CommentSection.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import Button from 'components/ui/Button';
+import Icon from 'components/ui/Icon';
+import CommentList from './CommentList';
+import CommentForm from './CommentForm';
+import { useRouter } from 'next/navigation';
+
+
+type CommentSectionProps = {
+ articleId: number;
+};
+
+function CommentSection({articleId}: CommentSectionProps) {
+ const router = useRouter();
+
+ const handleGoBack = () => {
+ router.back(); // ← 이전 페이지로 이동
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+export default CommentSection;
diff --git a/src/components/FallbackImage/FallbackImage.tsx b/src/components/FallbackImage/FallbackImage.tsx
index b66e6c57..26de11a8 100644
--- a/src/components/FallbackImage/FallbackImage.tsx
+++ b/src/components/FallbackImage/FallbackImage.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useState } from "react";
import Image, { ImageProps } from "next/image";
import { allowedImageDomains, defaultImg, imageExtensionRegex } from "@/lib/imageAssets";
+import { useState } from "react";
interface ImageWithFadeProps extends ImageProps {
fallbackSrc?: string;
@@ -45,8 +45,9 @@ export const FallbackImage = ({
alt={alt}
fill
priority
+ unoptimized
sizes="sm:100vw, 33vw"
- className={`absolute inset-0 object-cover blur-sm scale-105 transition-opacity duration-300 ${
+ className={`absolute inset-0 object-cover scale-105 transition-opacity duration-300 ${
isLoaded ? "opacity-0" : "opacity-100"
}`}
aria-hidden="true"
@@ -65,23 +66,6 @@ export const FallbackImage = ({
{...props}
/>
-
- //