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
60 changes: 60 additions & 0 deletions components/ArticleCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Image from "next/image";

import heartIcon from "@/public/icons/ic_heart.png";

const ArticleCard = ({ article }: { article: Article }) => {
return (
<div className="w-[343px] md:w-[696px] lg:w-[1200px] h-[136px] border-b border-b-[#E5E7EB]">
<div className="flex justify-between">
<div>{article.title}</div>
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={article.image}
alt={article.title}
className="w-[72px] h-[72px]"
/>
</div>
</div>
<div className="flex justify-between mt-[16px]">
<div className="flex gap-[8px]">
<div className="text-[#4B5563] text-[14px] font-[400]">
{article.writer.nickname}
</div>
<div className="text-[#9CA3AF] text-[14px] font-[400]">
{new Date(article.createdAt).toLocaleDateString("ko-KR")}
</div>
</div>
<div className="flex justify-center items-center gap-[4px]">
<div className="w-[16px] h-[16px]">
<Image src={heartIcon} alt="heartIcon" />
</div>
<div className="text-[#6B7280] text-[14px] font-[400]">
{article.likeCount}
</div>
</div>
</div>
</div>
);
};

export default ArticleCard;

type Article = {
id: number;
title: string;
content: string;
image: string;
likeCount: number;
createdAt: string;
updatedAt: string;
writer: {
id: number;
nickname: string;
};
};

type ArticleCardProps = {
article: Article;
onClick: () => void;
};
90 changes: 90 additions & 0 deletions components/ArticleCardList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useState, useRef, useCallback } from "react";

import ArticleSearch from "./ArticleSearch";
import ArticleDropdown from "./ArticleDropdown";
import ArticleCard from "./ArticleCard";

import useInfiniteArticles from "@/pages/hooks/useInfiniteArticles";

const ArticleCardList = () => {
const [sortOption, setSortOption] = useState<"recent" | "like">("recent");
const [searchKeyword, setSearchKeyword] = useState("");

const observer = useRef<IntersectionObserver | null>(null);

const { articles, setPage, hasMore } = useInfiniteArticles({
sortOption: sortOption,
keyword: searchKeyword,
pageSize: 10,
});

const lastArticleRef = useCallback(
(node: HTMLDivElement | null) => {
if (!hasMore) return;
if (observer.current) observer.current.disconnect();

observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setPage((prev) => prev + 1);
}
});

if (node) observer.current.observe(node);
},
[hasMore, setPage]
);

const handleSortChange = (option: "recent" | "like") => {
setSortOption(option);
};

return (
<div className="mb-[300px]">
<div className="flex justify-between mt-[24px] lg:mt-[40px]">
<div className="text-[#1F2937] text-[18px] md:text-[20px] font-[800] my-[8px]">
게시글
</div>
<div className="flex justify-center items-center w-[88px] h-[42px] bg-[#3692FF] text-[#FFFFFF] text-[16px] font-[600] rounded-[8px]">
글쓰기
</div>
</div>
<div className="flex gap-[13px] mt-[16px]">
<ArticleSearch onSearch={(keyword) => setSearchKeyword(keyword)} />
<ArticleDropdown
sortOption={sortOption}
handleSortChange={handleSortChange}
/>
</div>
<div className="flex flex-col gap-[24px]">
{articles.map((article, index) => {
const isLast = index === articles.length - 1;
return (
<div ref={isLast ? lastArticleRef : null} key={article.id}>
<ArticleCard article={article} />
</div>
);
})}
</div>
</div>
);
};

export default ArticleCardList;

type Article = {
id: number;
title: string;
content: string;
image: string;
likeCount: number;
createdAt: string;
updatedAt: string;
writer: {
id: number;
nickname: string;
};
};

type Articles = {
list: Article[];
};
98 changes: 98 additions & 0 deletions components/ArticleDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Image from "next/image";
import { useState } from "react";

import sortIcon from "@/public/icons/ic_sort.png";
import arrowDownIcon from "@/public/icons/ic_arrow_down.png";

const ArticleDropdown = ({
sortOption,
handleSortChange,
}: ArticleDropdownProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);

return (
<div>
{/* 모바일 버전 드롭다운 */}
<>
<button
onClick={() => setIsDropdownOpen((prev) => !prev)}
className="md:hidden cursor-pointer"
>
<div className="w-[42px] h-[42px] bg-[#FFFFFF] border border-[#E5E7EB] rounded-[12px] px-[9px] py-[9px]">
<div className="w-[24px] h-[24px]">
<Image src={sortIcon} alt="sortIcon" />
</div>
</div>
</button>
{isDropdownOpen && (
<div className="md:hidden relative mt-[4px] w-[140px] bg-white border border-[#E5E7EB] rounded-[12px] shadow-md z-10 right-[100px]">
<div
className="px-[19px] py-[10px] hover:bg-gray-100 cursor-pointer"
onClick={() => {
handleSortChange("recent");
setIsDropdownOpen(false);
}}
>
최신순
</div>
<div
className="px-[19px] py-[10px] hover:bg-gray-100 cursor-pointer"
onClick={() => {
handleSortChange("like");
setIsDropdownOpen(false);
}}
>
좋아요순
</div>
</div>
)}
</>

{/* 태블릿 이상 버전 드롭다운 */}
<>
<button
onClick={() => setIsDropdownOpen((prev) => !prev)}
className="hidden md:block cursor-pointer"
>
<div className="flex gap-[20px] w-[140px] h-[42px] bg-[#FFFFFF] border border-[#E5E7EB] rounded-[12px] pt-[9px] pb-[7px] px-[19px]">
<div className="h-[26px]">
{sortOption === "recent" ? "최신순" : "좋아요순"}
</div>
<div className="w-[24px] h-[24px]">
<Image src={arrowDownIcon} alt="arrowDownIcon" />
</div>
</div>
</button>
{isDropdownOpen && (
<div className="hidden md:block absolute mt-[4px] w-[140px] bg-white border border-[#E5E7EB] rounded-[12px] shadow-md z-10">
<div
className="px-[19px] py-[10px] hover:bg-gray-100 cursor-pointer"
onClick={() => {
handleSortChange("recent");
setIsDropdownOpen(false);
}}
>
최신순
</div>
<div
className="px-[19px] py-[10px] hover:bg-gray-100 cursor-pointer"
onClick={() => {
handleSortChange("like");
setIsDropdownOpen(false);
}}
>
좋아요순
</div>
</div>
)}
</>
</div>
);
};

export default ArticleDropdown;

type ArticleDropdownProps = {
sortOption: "recent" | "like";
handleSortChange: (option: "recent" | "like") => void;
};
37 changes: 37 additions & 0 deletions components/ArticleSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Image from "next/image";
import React, { useState } from "react";

import searchIcon from "@/public/icons/ic_search.png";

const ArticleSearch = ({
onSearch,
}: {
onSearch: (keyword: string) => void;
}) => {
const [inputValue, setInputValue] = useState("");

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
onSearch(inputValue);
}
};
return (
<div>
<div className="relative">
<input
type="text"
placeholder="검색할 상품을 입력해주세요"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
className="w-[288px] md:w-[560px] lg:w-[1054px] h-[42px] rounded-[12px] pt-[9px] pl-[55px] pr-[20px] pb-[9px] bg-[#F3F4F6]"
/>
</div>
<div className="w-[24px] h-[24px] relative bottom-[33px] left-[20px]">
<Image src={searchIcon} alt="searchIcon" />
</div>
</div>
);
};

export default ArticleSearch;
72 changes: 72 additions & 0 deletions components/BestArticleCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Image from "next/image";
import Link from "next/link";

import bestBedge from "@/public/images/best-bedge.png";
import heartIcon from "@/public/icons/ic_heart.png";

const BestArticleCard = ({ article, onClick }: ArticleCardProps) => {
return (
<div
className="flex flex-col mt-[30px] w-[343px] lg:w-[384px] h-[198px] bg-[#F9FAFB] rounded-[8px]"
onClick={onClick}
>
<div className="w-[102px] h-[30px] ml-[24px]">
<Image src={bestBedge} alt="bestBedge" />
</div>
<div className="flex flex-col w-[295px] h-[136px] mx-auto mt-[16px]">
<div className="flex justify-between">
<div className="text-[#1F2937] text-[18px] lg:text-[20px] w-[180px] lg:w-[256px] break-keep">
{article.title}
</div>
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={article.image}
alt={article.title}
className="w-[72px] h-[72px]"
/>
</div>
</div>
<div className="flex justify-between mt-[40px]">
<div className="flex gap-[8px]">
<div className="text-[#4B5563] text-[14px] font-[400]">
{article.writer.nickname}
</div>
<div className="flex justify-center items-center gap-[4px]">
<div className="w-[16px] h-[16px]">
<Image src={heartIcon} alt="heartIcon" />
</div>
<div className="text-[#6B7280] text-[14px] font-[400]">
{article.likeCount}
</div>
</div>
</div>
<div className="text-[#9CA3AF] text-[14px] font-[400]">
{new Date(article.createdAt).toLocaleDateString("ko-KR")}
</div>
</div>
</div>
</div>
);
};

export default BestArticleCard;

type Article = {
id: number;
title: string;
content: string;
image: string;
likeCount: number;
createdAt: string;
updatedAt: string;
writer: {
id: number;
nickname: string;
};
};

type ArticleCardProps = {
article: Article;
onClick: () => void;
};
25 changes: 25 additions & 0 deletions components/BestArticleCardList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import useFetchBestArticle from "@/pages/hooks/useFetchBestArticle";
import BestArticleCard from "@/components/BestArticleCard";

const BestArticleCardList = () => {
const { bestArticle } = useFetchBestArticle();

return (
<div>
<div className="text-[#1F2937] text-[18px] font-[800] md:text-[#111827] md:text-[20px]">
베스트 게시글
</div>
<div className="flex md:gap-[16px] lg:gap-[24px]">
{bestArticle?.list.map((article) => (
<BestArticleCard
key={article.id}
article={article}
onClick={() => console.log("상세페이지 들어감")}
/>
))}
</div>
</div>
);
};

export default BestArticleCardList;
Loading
Loading