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
137 changes: 137 additions & 0 deletions components/board/AllIArticleSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useEffect, useState, useRef, useCallback } from "react";
import axios from "@/lib/axios";
import Link from "next/link";
import styles from "@/styles/Boards.module.css";
import SearchForm from "@/components/ui/SearchForm";
import Dropdown from "@/components/button/DropdownButton";
import ArticleList from "@/components/board/ArticleList";

export default function AllArticleSection() {
const [articles, setArticles] = useState([]); //게시글 데이터
const [page, setPage] = useState(1); //쿼리스트링
const [pageSize] = useState(30); // 한 페이지당 게시글 수(무한스크롤 일단 임시코드)
const [orderBy, setOrderBy] = useState("recent"); // 기본 정렬: 최신순
const [keyword, setKeyword] = useState(""); // 검색 키워드

const [isLoading, setIsLoading] = useState(false); //로딩 상태
const [hasMore, setHasMore] = useState(true); //다음 페이지(데이터)가 있는지

const observer = useRef();

//게시글 불러오기
const getArticles = async (currentPage, currentOrderBy, currentKeyword) => {
setIsLoading(true);
try {
const response = await axios.get(`/articles`, {
params: {
page: currentPage,
pageSize,
orderBy: currentOrderBy,
keyword: currentKeyword,
},
});

const nextArticles = response.data.list;

// 첫 페이지(쿼리스트링)일 경우 새로운 데이터 업데이트, 아닐 경우 기존 데이터 배열 뒤에 추가
setArticles((prev) => {
if (currentPage === 1) return nextArticles;
return [...prev, ...nextArticles];
});

// 다음 페이지(데이터)가 있는지 확인
setHasMore(nextArticles.length > 0);
} catch (error) {
console.error("Error fetching articles:", error);
}
setIsLoading(false);
};

//임시 무한스크롤 코드
const lastArticleElementRef = useCallback(
(node) => {
if (isLoading) return;
if (observer.current) observer.current.disconnect();

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

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

//드롭다운 데이터
const handleOrderChange = (value) => {
const order = value === "최신순" ? "recent" : "like";
setOrderBy(order);
setPage(1);
getArticles(1, order, keyword);
};

//검색
const handleSearch = (searchKeyword) => {
setKeyword(searchKeyword);
setPage(1);
getArticles(1, orderBy, searchKeyword);

if (searchKeyword === "") {
getArticles(1, orderBy, ""); // 기본 게시글 목록 호출
} else {
getArticles(1, orderBy, searchKeyword); // 검색된 게시글 목록 호출
}
};

//최초 게시글 불러오기
useEffect(() => {
getArticles(page, orderBy, keyword);
}, [page]);

return (
<>
<div className={styles.container}>
<div className={styles.allArticle}>
<p className={styles.containerTitle}>게시글</p>
<Link className={`${styles.button} button`} href="/boards/write">
글쓰기
</Link>
</div>

<div className={styles.searchContainer}>
<SearchForm onSearch={handleSearch} />
<Dropdown onSelect={handleOrderChange} />
</div>

<ArticleList articles={articles} />

<div>
{articles.map((article, index) => {
if (index === articles.length - 1) {
return (
<div key={article.id} ref={lastArticleElementRef}>
<h3>{article.title}</h3>
<p>{article.content}</p>
</div>
);
} else {
return (
<div key={article.id}>
<h3>{article.title}</h3>
<p>{article.content}</p>
</div>
);
}
})}
</div>

{isLoading && <p className={styles.loading}>로딩 중...</p>}
{!hasMore && (
<p className={styles.noMoreData}>더 이상 게시글이 없습니다.</p>
)}
</div>
</>
);
}
58 changes: 58 additions & 0 deletions components/board/ArticleList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Link from "next/link";
import Image from "next/image";
import like from "@/public/icons/ic_heart.svg";
import profile from "@/public/icons/ic_profile.png";
import styles from "@/styles/Boards.module.css";
import defaultImg from "@/public/icons/img_default.png";

export default function ArticleList({ articles = [] }) {
return (
<div>
{articles.map((articles) => (
<div key={articles.id} className={styles.articleContainer}>
<div className={styles.articleInfo}>
<Link href={`/boards/${articles.id}`}>
<p className={styles.articleTitle}>{articles.title}</p>
</Link>
{articles.image ? (
<Image
src={articles.image}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src에서 삼항연산자를 사용하지 않고 이미지 컴포넌트를 별도로 렌더링하는 이유가 있나요?

alt={articles.title || "대표이미지"}
width={72}
height={72}
style={{
borderRadius: "8px",
border: "1px solid var(--gray-100)",
}}
/>
) : (
<Image
src={defaultImg}
alt={"대표이미지"}
width={72}
height={72}
style={{
borderRadius: "8px",
border: "1px solid var(--gray-100)",
}}
/>
)}
</div>
<div className={styles.writerInfo}>
<div className={styles.writer}>
<Image src={profile} alt="profile" width={24} height={24} />
<p className={styles.nickname}>{articles.writer.nickname}</p>
<p className={styles.date}>
{new Date(articles.createdAt).toLocaleDateString()}
</p>
</div>
<div className={styles.like}>
<Image src={like} alt="likeCount" />
<p className={styles.likeCount}>{articles.likeCount}</p>
</div>
</div>
</div>
))}
</div>
);
}
65 changes: 65 additions & 0 deletions components/board/BestArticle.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Link from "next/link";
import Image from "next/image";
import like from "@/public/icons/ic_heart.svg";
import profile from "@/public/icons/ic_profile.png";
import styles from "@/styles/Boards.module.css";
import defaultImg from "@/public/icons/img_default.png";
import badge from "@/public/icons/img_badge.png";

export default function BestArticle({ articles = [] }) {
return (
<div className={styles.bestArticle}>
{articles.map((articles) => (
<div key={articles.id} className={styles.bestArticleContainer}>
<Image
src={badge}
alt="badge"
width={102}
style={{ alignItems: "top" }}
/>
<div className={styles.articleInfo}>
<Link href={`/boards/${articles.id}`}>
<p className={styles.articleTitle}>{articles.title}</p>
</Link>
{articles.image ? (
<Image
src={articles.image}
alt={articles.title || "대표이미지"}
width={72}
height={72}
style={{
borderRadius: "8px",
border: "1px solid var(--gray-100)",
}}
/>
) : (
<Image
src={defaultImg}
alt={"대표이미지"}
width={72}
height={72}
style={{
borderRadius: "8px",
border: "1px solid var(--gray-100)",
}}
/>
)}
</div>
<div className={styles.writerInfo}>
<div className={styles.writer}>
<Image src={profile} alt="profile" width={24} height={24} />
<p className={styles.nickname}>{articles.writer.nickname}</p>
<p className={styles.date}>
{new Date(articles.createdAt).toLocaleDateString()}
</p>
</div>
<div className={styles.like}>
<Image src={like} alt="likeCount" />
<p className={styles.likeCount}>{articles.likeCount}</p>
</div>
</div>
</div>
))}
</div>
);
}
38 changes: 38 additions & 0 deletions components/board/BestArticleSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import axios from "@/lib/axios";
import styles from "@/styles/Boards.module.css";
import BestArticle from "./BestArticle";

export default function BestArticleSection() {
const [bestArticles, setBestArticles] = useState([]);

// 베스트 게시글 불러오기
async function getBestArticles() {
try {
const res = await axios.get("/articles");
const nextBestArticles = res.data.list;

// likeCount 기준으로 정렬 후 상위 3개 추출
const sortedArticles = nextBestArticles
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

articles 관련 정렬을 api에 쿼리 파라미터로 orderBy 설정을 제공하기 때문에 이걸 활용해서 like 기준으로 정렬하는 방향이 좋아요. (참고)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아, orderby를 활용하는 방법으로 가는게 더 낫겠네요..! 말씀해주신 방법으로 수정해보겠습니다

.sort((a, b) => b.likeCount - a.likeCount)
.slice(0, 3);

setBestArticles(sortedArticles);
} catch (error) {
console.error("Error fetching articles:", error);
}
}

useEffect(() => {
getBestArticles();
}, []);

return (
<>
<div className={styles.container}>
<p className={styles.containerTitle}>베스트 게시글</p>
<BestArticle articles={bestArticles} />
</div>
</>
);
}
88 changes: 88 additions & 0 deletions components/button/DropdownButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useState } from "react";
import styled from "styled-components";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

styled-components 와 module.css 혼용해서 사용중인 걸로 보이는데,
학습 목적이 아니라면 둘 중 하나를 사용하는 방향이 좋아 보여요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보통 두개를 혼합해서 계속 사용했었는데, 하나로만 사용하는게 맞는건가요? 어떤 방식을 더 추천하는지 알려주시면 감사하겠습니다 !

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로젝트를 진행하거나 업무적으로 진행할 때는 하나의 서비스에서는 스타일링을 하나로 통일하는 편이 좋아요.
그래야 스타일링 코드에 대한 예측가능성이 높아지고, 코드를 읽거나 수정하는 방식에 대해서도 두 가지로 나눠서 생각하지 않아도 되기 때문에 그래요.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

styled-components 와 css modules 는 둘 중 어떤 걸 사용하셔도 무방하다고 생각해요.
다만, 각자의 방식이 가지고 있는 장점과 한계에 대한 이해는 가지고 사용하는게 필요해요.


const DropdownContainer = styled.div`
position: relative;
`;

const DropdownButton = styled.button`
width: 100%;
padding: 12px 20px;
margin: auto;
font-size: 16px;
font-weight: 400;
text-align: center;
background-color: #ffffff;
border: 1px solid #ccc;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;

&:hover {
background-color: #f9f9f9;
}
`;

const DropdownList = styled.ul`
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
background-color: #ffffff;
border: 1px solid #ccc;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
`;

const DropdownItem = styled.li`
display: block;
justify-content: center;
align-items: center;
padding: 12px 20px;
font-size: 16px;
line-height: 26px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;

&:hover {
background-color: #f0f0f0;
}
`;

// DropdownButton.jsx
const Dropdown = ({ onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState("최신순");

const toggleDropdown = () => setIsOpen(!isOpen);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setIsOpen((prev) => !prev) 가 이전 상태를 가져온다는 걸 보장할 수 있어 조금 더 안전한 코드에요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 이해했습니다. 참고하여 수정하겠습니다 !


// 선택된 값에 따라 상태 업데이트
const handleSelect = (value) => {
setSelected(value);
setIsOpen(false); // 드롭다운 닫기
onSelect(value); // 선택된 값 부모로 전달
};

return (
<DropdownContainer>
<DropdownButton onClick={toggleDropdown}>{selected} ㅤㅤ▼</DropdownButton>
{isOpen && (
<DropdownList>
<DropdownItem onClick={() => handleSelect("최신순")}>
최신순
</DropdownItem>
<DropdownItem onClick={() => handleSelect("인기순")}>
인기순
</DropdownItem>
</DropdownList>
)}
</DropdownContainer>
);
};

export default Dropdown;
Loading
Loading