Skip to content
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

[강범준] sprint10 #153

Open
wants to merge 3 commits into
base: next-강범준
Choose a base branch
from
Open
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
56 changes: 31 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,46 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## **기본 요구사항**

## Getting Started
**공통**

First, run the development server:
- [x] Github에 위클리 미션 PR을 만들어 주세요.
- [x] React.js 혹은 Next.js를 사용해 진행합니다.

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
### **프론트엔드 구현 요구사항**

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
**중고마켓 페이지**

You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
- [ ] 디폴트 이미지로 처리한 이미지를 실제 Product Get API에서 가져온 이미지로 변경해 주세요.
- [ ] 좋아요 순 정렬 기능을 붙여주세요.
- [ ] 베스트 상품 기능을 추가해 주세요. 베스트 상품은 가장 많이 좋아요를 받은 순으로 PC 기준 최대 4개까지 조회 가능합니다.

[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
**상품 등록하기 페이지**

The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
- [ ] 상품 이미지 등록 기능을 구현합니다. 파일을 선택해 이미지를 업로드하고, preview를 볼 수 있도록 구현합니다. 이미지는 최대 3개까지만 등록 가능하도록 구현해 주세요.
- [ ] 동일하게 상품 이미지 수정 기능도 추가합니다.
- [ ] 상품 등록 성공 시 중고마켓 페이지로 이동해 주세요.

This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## **심화 요구사항**

## Learn More
**상태코드 (웹 API 관련)**

To learn more about Next.js, take a look at the following resources:
- [ ] 프론트엔드에서는 서버 응답의 상태코드에 따라 적절한 사용자 피드백을 제공합니다.

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
**인증**

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
- [ ] 토큰 기반 방식을 사용할 경우, 만료된 액세스 토큰을 새로 발급하는 리프레시 토큰 발급 기능을 구현합니다.(jwt sliding session 적용)

## Deploy on Vercel
**OAuth를 활용한 인증**

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
- [ ] 구글 OAuth를 사용하여 회원가입 및 로그인 기능을 구현합니다.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
**프로젝트 구조 변경**

- [ ] 프로젝트의 구조와 복잡성을 관리하기 위해 MVC 패턴이나 Layered Architecture와 같은 설계 방식을 적용해 보세요.

**(생략 가능) 자유게시판 게시물 등록**

- [ ] 프론트엔드를 Next.js로 Migration 했을 경우에만 진행해 주세요.
- [ ] 게시물 등록 시 이미지 등록 기능을 구현합니다. 파일을 선택해 이미지를 업로드하고, preview를 볼 수 있도록 구현합니다. 이미지는 최대 3개까지만 등록 가능하도록 구현해 주세요.
- [ ] 게시물 등록 시 필요한 필드(제목, 내용 등)의 유효성 검증하는 미들웨어를 구현합니다.
- [ ] `multer` 미들웨어를 사용하여 이미지 업로드 API를 구현해 주세요.
- [ ] 업로드된 이미지는 서버에 저장하고, 해당 이미지의 경로를 response 객체에 포함해 반환합니다.
97 changes: 97 additions & 0 deletions api/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// api.js
import axiosInstance from "../lib/axios";

// 사용자 정보 가져오기
export const fetchCurrentUser = async () => {
const response = await axiosInstance.get("/users/me");
return response.data;
};

// 상품 목록 가져오기
export const fetchProducts = async (params) => {
const response = await axiosInstance.get("/products", { params });
return response.data;
};

// 상품 등록
export const createProduct = async (productData) => {
const response = await axiosInstance.post("/products", productData);
return response.data;
};

// 상품 상세 정보 가져오기
export const fetchProductById = async (productId) => {
const response = await axiosInstance.get(`/products/${productId}`);
return response.data;
};

// 상품 수정
export const editProduct = async (productId, productData) => {
const response = await axiosInstance.patch(
`/products/${productId}`,
productData
);
return response.data;
};

// 상품 삭제
export const deleteProduct = async (productId) => {
const response = await axiosInstance.delete(`/products/${productId}`);
return response.data;
};

// 상품 댓글 목록 가져오기
export const fetchProductComments = async (productId, params) => {
const response = await axiosInstance.get(`/products/${productId}/comments`, {
params,
});
return response.data;
};

// 상품 댓글 작성
export const postProductComment = async ({ productId, content }) => {
const response = await axiosInstance.post(`/products/${productId}/comments`, {
content,
});
return response.data;
};

// 댓글 수정
export const editComment = async (commentId, content) => {
const response = await axiosInstance.patch(`/comments/${commentId}`, {
content,
});
return response.data;
};

// 댓글 삭제
export const deleteComment = async (commentId) => {
const response = await axiosInstance.delete(`/comments/${commentId}`);
return response.data;
};

// 상품 좋아요 추가
export const postProductFavorite = async (productId) => {
const response = await axiosInstance.post(`/products/${productId}/favorite`);
return response.data;
};

// 상품 좋아요 취소
export const deleteProductFavorite = async (productId) => {
const response = await axiosInstance.delete(
`/products/${productId}/favorite`
);
return response.data;
};

// 회원가입 요청
export const signUp = async (userData) => {
const response = await axiosInstance.post("/auth/signUp", userData);
return response.data;
};

// 로그인 요청
export const logIn = async (userData) => {
const response = await axiosInstance.post("/auth/signIn", userData);
return response.data;
};
2 changes: 1 addition & 1 deletion components/ArticleList.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "@/pages/api/axios";
import axios from "@/lib/axios.js";
import Article from "./Article";
import Image from "next/image";
import Link from "next/link";
Expand Down
2 changes: 1 addition & 1 deletion components/BestArticleList.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "@/pages/api/axios";
import axios from "@/lib/axios.js";
import BestArticleCard from "./BestArticleCard";
import styles from "./BestArticleList.module.css";

Expand Down
2 changes: 1 addition & 1 deletion components/CommentList.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import styles from "@/styles/CommentList.module.css";
import { useState } from "react";
import Image from "next/image";
import axios from "@/pages/api/axios";
import axios from "@/lib/axios.js";

export default function Comment({ comment }) {
const [isOpen, setIsOpen] = useState(false);
Expand Down
101 changes: 101 additions & 0 deletions components/CommentOptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// components/CommentOptions.js

import React, { useState } from "react";
import Image from "next/image";
import styles from "./CommentOptions.module.css";

const CommentOptions = ({ comments, onEdit, onDelete }) => {
const [editingCommentId, setEditingCommentId] = useState(null);
const [editedContent, setEditedContent] = useState("");

const handleEditClick = (comment) => {
setEditingCommentId(comment.id);
setEditedContent(comment.content);
};

const handleSaveClick = (commentId) => {
onEdit(commentId, editedContent);
setEditingCommentId(null);
};

const handleCancelClick = () => {
setEditingCommentId(null);
setEditedContent("");
};

const [showOptions, setShowOptions] = useState(null);

const handleCommentOptions = (commentId) => {
setShowOptions((prev) => (prev === commentId ? null : commentId));
};

return (
<div className={styles.comments}>
{comments.map((comment) => (
<div key={comment.id} className={styles.comment}>
<div className={styles.profileImageContainer}>
<Image
src="/ic_profile.png"
alt="프로필"
width={40}
height={40}
className={styles.profileImage}
/>
</div>
<div className={styles.commentContentWrapper}>
{editingCommentId === comment.id ? (
// 인라인 편집 폼
<div className={styles.editForm}>
<textarea
className={styles.editTextarea}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
/>
<div className={styles.editActions}>
<button onClick={() => handleSaveClick(comment.id)}>
저장
</button>
<button onClick={handleCancelClick}>취소</button>
</div>
</div>
) : (
// 댓글 내용 표시
<>
<p className={styles.commentContent}>{comment.content}</p>
<p className={styles.commentMeta}>
{comment.writer.nickname} -{" "}
{new Date(comment.createdAt).toLocaleString()}
<div className={styles.commentActions}>
<button
onClick={() => handleCommentOptions(comment.id)}
className={styles.commentOptionsButton}
>
<Image
src="/ic_kebab.png"
alt="옵션"
width={20}
height={20}
/>
</button>
{showOptions === comment.id && (
<div className={styles.optionsPopup}>
<button onClick={() => handleEditClick(comment)}>
수정하기
</button>
<button onClick={() => onDelete(comment.id)}>
삭제하기
</button>
</div>
)}
</div>
</p>
</>
)}
</div>
</div>
))}
</div>
);
};

export default CommentOptions;
93 changes: 93 additions & 0 deletions components/CommentOptions.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/* components/CommentOptions.module.css */

.comments {
display: flex;
flex-direction: column;
gap: 10px;
width: 1200px; /* 전체 container 사이즈를 1200으로 설정 */
height: 100px; /* 전체 container 사이즈를 100으로 설정 */
}

.comment {
display: flex;
align-items: flex-start;
border-bottom: 1px solid #eee;
padding: 10px 0;
}

.profileImageContainer {
margin-right: 15px;
}

.profileImage {
border-radius: 50%;
}

.commentContentWrapper {
flex-grow: 1;
}

.commentContent {
margin-bottom: 5px;
}

.commentMeta {
font-size: 12px;
color: #666;
display: flex;
align-items: center;
}

.commentActions {
position: relative;
margin-left: auto; /* 오른쪽 끝에 위치시키기 위해 auto 사용 */
}

.commentOptionsButton {
background: none;
border: none;
cursor: pointer;
padding: 0;
position: absolute; /* 절대 위치로 변경 */
right: 35px; /* 오른쪽 끝에서 20px 왼쪽으로 이동 */
bottom: 10px; /* 위쪽으로 10px 이동 */
}

.optionsPopup {
position: absolute;
top: 25px;
right: 0;
width: 139px;
background-color: white;
border: 1px solid #ddd;
z-index: 1000;
}

.optionsPopup button {
width: 100%;
padding: 10px 0;
background: none;
border: none;
cursor: pointer;
}

.optionsPopup button:hover {
background-color: #f0f0f0;
}

/* 인라인 편집 폼 스타일 */
.editForm {
display: flex;
flex-direction: column;
}

.editTextarea {
width: 100%;
height: 80px;
resize: vertical;
margin-bottom: 10px;
}

.editActions button {
margin-right: 5px;
}
Loading