Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0d61621
fix: Pagination 컴포넌트의 페이지 계산 로직 개선 및 버튼 타입 추가
minimo-9 May 10, 2025
6a3ca26
fix: SearchBar 컴포넌트의 props 이름을 searchTerm에서 keyword로 변경
minimo-9 May 10, 2025
41894fd
fix: SearchBar 컴포넌트의 props를 searchTerm에서 keyword로 변경하고 useSearchParam…
minimo-9 May 10, 2025
d2fe926
fix: SearchBar 컴포넌트의 포커스 시 테두리 스타일 수정
minimo-9 May 10, 2025
7c23160
fix: SortSelector 컴포넌트에 value prop 추가 및 선택된 정렬 상태 표시 개선
minimo-9 May 10, 2025
3675dc0
fix: SortSelector 컴포넌트에 value prop 추가 및 SearchBar 컴포넌트의 keyword prop 적용
minimo-9 May 10, 2025
9f68c32
fix: SortSelector 컴포넌트의 정렬 로직 단순화 및 선택된 정렬 상태 표시 개선
minimo-9 May 10, 2025
1ef9bc1
fix: AllProducts 컴포넌트의 검색 및 정렬 핸들러를 useCallback으로 최적화
minimo-9 May 10, 2025
78217de
fix: AllProducts 컴포넌트의 페이지 크기 계산 로직 개선
minimo-9 May 10, 2025
949fdaf
fix: AllProducts 컴포넌트에서 페이지 변경 시 sessionStorage 업데이트 로직 제거 및 제품 목록 렌더…
minimo-9 May 10, 2025
137213b
fix: setSearchParams에 replace 옵션 추가하여 검색 및 정렬 시 페이지 변경 시 URL 업데이트 개선
minimo-9 May 10, 2025
28e7290
feat: plus-icon.svg 파일 추가
minimo-9 May 10, 2025
91d3d43
feat: close-icon.svg 파일 추가
minimo-9 May 10, 2025
eab222d
fix: useLocation 훅을 추가하여 현재 경로에 따라 링크 스타일 개선
minimo-9 May 10, 2025
d57591c
feat: close-icon.svg 크기 및 경로 수정
minimo-9 May 10, 2025
dc5f952
fix: 검색바 높이 및 패딩 수정
minimo-9 May 10, 2025
17eac06
fix: AddItem 경로 수정
minimo-9 May 10, 2025
8e6aead
feat: 이미지 업로드 컴포넌트 및 스타일 추가
minimo-9 May 10, 2025
3396fa3
feat: 상품 소개 컴포넌트 및 스타일 추가
minimo-9 May 10, 2025
dd5e92a
feat: 상품명 입력 컴포넌트 및 스타일 추가
minimo-9 May 10, 2025
f56c4bc
feat: 가격 입력 컴포넌트 및 스타일 추가
minimo-9 May 10, 2025
8a33f59
feat: 태그 입력 컴포넌트 및 스타일 추가
minimo-9 May 10, 2025
05af39b
feat: 상품 등록 컴포넌트 및 스타일 추가
minimo-9 May 10, 2025
cbc757c
feat: AddItem 스타일 추가
minimo-9 May 10, 2025
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
2 changes: 1 addition & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom";
import Header from "./components/Header/Header.jsx";
import Items from "./pages/Items/Items.jsx";
import AddItem from "./pages/AddItem.jsx";
import AddItem from "./pages/AddItem/AddItem.jsx";
import "./assets/styles/base.css";

function App() {
Expand Down
5 changes: 5 additions & 0 deletions src/assets/icon/close-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/icon/plus-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 66 additions & 41 deletions src/components/AllProducts/AllProducts.jsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useState, useEffect, useCallback } from "react";
import { Link, useSearchParams } from "react-router-dom";
import getProducts from "../../api/getProducts";
import ProductCard from "../ProductCard/ProductCard";
import SearchBar from "../SearchBar/SearchBar";
import SortSelector from "../SortSelector/SortSelector";
import Pagination from "../Pagination/Pagination";
import styles from "./AllProducts.module.css";

const getPageSize = () => {
const width = window.innerWidth;
if (width < 768) return 4;
else if (width < 1024) return 6;
else if (width < 1200) return 8;
else return 10;
};

const AllProducts = () => {
const [products, setProducts] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [orderBy, setOrderBy] = useState("recent");
const [pageSize, setPageSize] = useState(10);
const [pageSize, setPageSize] = useState(getPageSize);
const [totalCount, setTotalCount] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const orderBy = searchParams.get("orderBy") || "recent";
const keyword = searchParams.get("keyword") || "";
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);

const [page, setPage] = useState(() => {
const saved = sessionStorage.getItem("allProductsPage");
return saved ? parseInt(saved, 10) : 1;
});

useEffect(() => {
sessionStorage.setItem("allProductsPage", page.toString());
}, [page]);
const page = Number(searchParams.get("page")) || 1;

useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
Expand All @@ -31,40 +32,64 @@ const AllProducts = () => {
}, []);

useEffect(() => {
getProducts({ page, pageSize, orderBy, keyword: searchTerm })
getProducts({ page, pageSize, orderBy, keyword })
.then((data) => {
setProducts(data.list);
setTotalCount(data.totalCount);
})
.catch((error) => console.error(error));
}, [page, pageSize, orderBy, searchTerm]);
}, [page, pageSize, orderBy, keyword]);

useEffect(() => {
const updatePageSize = () => {
const width = window.innerWidth;
if (width < 768) setPageSize(4);
else if (width < 1024) setPageSize(6);
else if (width < 1200) setPageSize(8);
else setPageSize(10);
setPageSize(getPageSize());
};

updatePageSize();
window.addEventListener("resize", updatePageSize);
return () => window.removeEventListener("resize", updatePageSize);
}, []);

const sortedProducts = [...products].sort((a, b) => {
if (orderBy === "likes") return b.favoriteCount - a.favoriteCount;
return new Date(b.recent) - new Date(a.recent);
});
const onSearch = useCallback(
(term) => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.set("keyword", term);
newParams.set("page", "1");
return newParams;
},
{ replace: true }
);
},
[setSearchParams]
);

const filteredData = sortedProducts.filter((item) =>
item.name?.toLowerCase().includes(searchTerm.toLocaleLowerCase())
const handleSortChange = useCallback(
(sortKey) => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.set("orderBy", sortKey);
newParams.set("page", "1");
return newParams;
},
{ replace: true }
);
},
[setSearchParams]
);

const onSearch = (term) => {
setSearchTerm(term);
};
const handlePageChange = useCallback(
(newPage) => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set("page", String(newPage));
return params;
});
},
[setSearchParams]
);

return (
<>
Expand All @@ -78,30 +103,30 @@ const AllProducts = () => {
</Link>
</div>
<div className={styles.mobileControlRow}>
<SearchBar searchTerm={searchTerm} onSearch={onSearch} />
<SortSelector onChange={setOrderBy} />
<SearchBar onSearch={onSearch} keyword={keyword} />
<SortSelector value={orderBy} onChange={handleSortChange} />
</div>
</div>
) : (
<div className={styles.topBarContainer}>
<h2 className={styles.sectionTitle}>전체 상품</h2>
<div className={styles.controlsRow}>
<SearchBar searchTerm={searchTerm} onSearch={onSearch} />
<SearchBar onSearch={onSearch} keyword={keyword} />
<Link to={"/additem"} className={styles.buttonLink}>
<button className={styles.addItemButton}>상품 등록하기</button>
</Link>
<SortSelector onChange={(sortKey) => setOrderBy(sortKey)} />
<SortSelector value={orderBy} onChange={handleSortChange} />
</div>
</div>
)}
<div className={styles.productList}>
{filteredData.map((item) => (
{products.map(({ id, images, name, price, favoriteCount }) => (
<ProductCard
key={item.id}
imageUrl={item.images?.[0]}
title={item.name}
price={item.price}
likes={item.favoriteCount}
key={id}
imageUrl={images?.[0]}
title={name}
price={price}
likes={favoriteCount}
variant="all"
/>
))}
Expand All @@ -111,7 +136,7 @@ const AllProducts = () => {
currentPage={page}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
onPageChange={handlePageChange}
/>
</>
);
Expand Down
15 changes: 9 additions & 6 deletions src/components/Header/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Link, NavLink } from "react-router-dom";
import { Link, NavLink, useLocation } from "react-router-dom";
import styles from "./Header.module.css";

const getLinkStyle = ({ isActive }) => {
return {
color: isActive ? "#3692ff" : "inherit",
const Header = () => {
const location = useLocation();

const getLinkStyle = ({ isActive }) => {
const isAddItemPage = location.pathname === "/additem";
return {
color: isActive || isAddItemPage ? "#3692ff" : "inherit",
};
};
};

const Header = () => {
return (
<div className={styles.header}>
<div className={styles.container}>
Expand Down
62 changes: 62 additions & 0 deletions src/components/ImageUpload/ImageUpload.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useState } from "react";
import plusIcon from "./../../assets/icon/plus-icon.svg";
import closeIcon from "./../../assets/icon/close-icon.svg";
import styles from "./ImageUpload.module.css";

const ImageUpload = ({ image, onImageChange }) => {
const [error, setError] = useState("");

const handleChange = (e) => {
const file = e.target.files[0];

if (!file) return;

if (image) {
setError("*이미지 등록은 최대 1개까지 가능합니다.");
return;
}

const reader = new FileReader();
reader.onloadend = () => {
setError("");
onImageChange(reader.result);
};
reader.readAsDataURL(file);
};

const handleRemove = () => {
setError("");
onImageChange(null);
};

return (
<div className={styles.imageUpload}>
<h2 className={styles.label}>상품 이미지</h2>
<div className={styles.imageRow}>
<label className={styles.uploadBox}>
<img src={plusIcon} alt="추가 아이콘" className={styles.plus} />
<p className={styles.text}>이미지 등록</p>
<input
type="file"
accept="image/*"
Copy link
Collaborator

Choose a reason for hiding this comment

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

💊 제안
input의 accept 속성은 유저가 어떤 파일을 올려야하는지에 대한 힌트를 제공하는 속성입니다.
유저는 파일 업로드시 accept의 명시된 확장자 이외의 파일도 올릴 수 있으므로
실제 upload 함수에서 한번더 확장자를 검사해주시는 것이 좋습니다.

(사용자가 업로드창에서 옵션을 열어 확장자를 바꾸면 아래처럼 보입니다)
스크린샷 2025-05-08 오후 5 53 17

https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/accept

onChange={handleChange}
className={styles.uploadInput}
/>
</label>

{image && (
<div className={styles.previewBox}>
<img src={image} alt="미리보기" className={styles.previewImage} />
<button onClick={handleRemove} className={styles.removeButton}>
<img src={closeIcon} alt="닫기 아이콘" className={styles.close} />
</button>
</div>
)}
</div>

{error && <p className={styles.error}>{error}</p>}
</div>
);
};

export default ImageUpload;
91 changes: 91 additions & 0 deletions src/components/ImageUpload/ImageUpload.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
.imageUpload {
display: flex;
flex-direction: column;
width: 100%;
max-width: 588px;
gap: 16px;
}

.label {
font-size: 18px;
font-weight: 700;
line-height: 26px;
color: #1f2937;
}

.imageRow {
display: flex;
width: 100%;
gap: 24px;
}

.uploadBox {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
max-width: 282px;
height: 282px;
border-radius: 12px;
background-color: #f3f4f6;
gap: 12px;
cursor: pointer;
}

.plus {
width: 48px;
height: auto;
}

.text {
font-size: 16px;
font-weight: 400;
line-height: 26px;
color: #9ca3af;
}

.uploadInput {
display: none;
}

.previewBox {
position: relative;
width: 100%;
max-width: 282px;
height: 282px;
}

.previewImage {
width: 100%;
height: 100%;
border: 1px solid #f9fafb;
border-radius: 12px;
object-fit: cover;
}

.removeButton {
position: absolute;
top: 12px;
right: 12px;
cursor: pointer;
}

.error {
font-size: 16px;
font-weight: 400;
line-height: 26px;
color: #f74747;
}

@media (max-width: 1023px) {
.uploadBox {
max-width: 168px;
height: 168px;
}

.previewBox {
max-width: 168px;
height: 168px;
}
}
23 changes: 23 additions & 0 deletions src/components/ItemDescription/ItemDescription.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useId } from "react";
import styles from "./ItemDescription.module.css";

const ItemDescription = ({ value, onChange }) => {
const id = useId();

return (
<div className={styles.itemDescription}>
<label htmlFor={id} className={styles.label}>
상품 소개
</label>
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="상품 소개를 입력해주세요"
className={styles.textarea}
/>
</div>
);
};

export default ItemDescription;
Loading
Loading