-
Notifications
You must be signed in to change notification settings - Fork 37
[정혜연] sprint9 #315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "Next-\uC815\uD61C\uC5F0-Sprint9"
[정혜연] sprint9 #315
Changes from all commits
93a0e93
b2e37bd
6f8bbb0
e11e25f
212e864
4dc5dd0
2180dc6
85de37a
4d9f3a7
7e8b8da
ec74ffa
7c53fe4
7448621
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| </> | ||
| ); | ||
| } |
| 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} | ||
| 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> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. articles 관련 정렬을 api에 쿼리 파라미터로 orderBy 설정을 제공하기 때문에 이걸 활용해서 like 기준으로 정렬하는 방향이 좋아요. (참고)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import React, { useState } from "react"; | ||
| import styled from "styled-components"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. styled-components 와 module.css 혼용해서 사용중인 걸로 보이는데,
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 보통 두개를 혼합해서 계속 사용했었는데, 하나로만 사용하는게 맞는건가요? 어떤 방식을 더 추천하는지 알려주시면 감사하겠습니다 !
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 프로젝트를 진행하거나 업무적으로 진행할 때는 하나의 서비스에서는 스타일링을 하나로 통일하는 편이 좋아요.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
src에서 삼항연산자를 사용하지 않고 이미지 컴포넌트를 별도로 렌더링하는 이유가 있나요?