diff --git a/README.md b/README.md index 3430e845c..02f43d34e 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,159 @@ -# FE9 Weekly Mission +# FE9 Sprint Mission -이 프로젝트는 [Next.js](https://nextjs.org)를 기반으로 만들어진 주간 미션 프로젝트입니다. +## 소개 -## 배포 사이트 +이 프로젝트는 Next.js Page Router 기반의 커뮤니티 및 중고마켓 플랫폼입니다. 사용자들은 게시글을 작성하고, 상품을 등록하며, 댓글과 좋아요 기능을 통해 상호작용할 수 있습니다. -프로젝트는 다음 URL에서 확인할 수 있습니다: [https://next-panda-market.vercel.app](https://next-panda-market.vercel.app) +## 주요 기능 -## 시작하기 +### 커뮤니티 -먼저, 개발 서버를 실행하세요: +- 게시글 CRUD +- 댓글 시스템 +- 좋아요 기능 +- 베스트 게시글 +- 실시간 검색 +- 정렬 기능 (최신순/인기순) + +### 중고마켓 + +- 상품 등록/수정/삭제 +- 상품 문의 +- 찜하기 기능 +- 베스트 상품 +- 검색 및 정렬 + +## 기술 스택 + +### 핵심 의존성 + +[![Next.js](https://img.shields.io/badge/Next.js-14.2.12-black?logo=next.js)](https://nextjs.org/) +[![React](https://img.shields.io/badge/React-18-blue?logo=react)](https://reactjs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript)](https://www.typescriptlang.org/) +[![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3.4.13-38B2AC?logo=tailwind-css)](https://tailwindcss.com/) + +### 상태 관리 & API + +[![React Query](https://img.shields.io/badge/@tanstack/react--query-5.59.20-FF4154?logo=react-query)](https://tanstack.com/query) +[![Jotai](https://img.shields.io/badge/Jotai-2.10.0-black)](https://jotai.org/) +[![Axios](https://img.shields.io/badge/Axios-1.7.7-5A29E4?logo=axios)](https://axios-http.com/) + +### 폼 & 유효성 검증 + +[![React Hook Form](https://img.shields.io/badge/React%20Hook%20Form-7.53.1-EC5990)](https://react-hook-form.com/) +[![HookForm Resolvers](https://img.shields.io/badge/@hookform/resolvers-3.9.1-EC5990)](https://github.com/react-hook-form/resolvers) +[![Zod](https://img.shields.io/badge/Zod-3.23.8-3068B7)](https://zod.dev/) + +### UI/UX + +[![React Hot Toast](https://img.shields.io/badge/React%20Hot%20Toast-2.4.1-FF4444)](https://react-hot-toast.com/) +[![React Spinners](https://img.shields.io/badge/React%20Spinners-0.14.1-36D7B7)](https://www.davidhu.io/react-spinners/) +[![Tailwind Merge](https://img.shields.io/badge/Tailwind%20Merge-2.5.2-38B2AC)](https://github.com/dcastil/tailwind-merge) + +### 유틸리티 + +[![Sharp](https://img.shields.io/badge/Sharp-0.33.5-99CC00)](https://sharp.pixelplumbing.com/) +[![date-fns](https://img.shields.io/badge/date--fns-4.1.0-yellow)](https://date-fns.org/) +[![Form Data](https://img.shields.io/badge/Form%20Data-4.0.0-green)](https://github.com/form-data/form-data) +[![Formidable](https://img.shields.io/badge/Formidable-3.5.1-orange)](https://github.com/node-formidable/formidable) + +## 프로젝트 설정 + +### 환경 설정 + +1. 의존성 설치: + +```bash +npm install +``` + +2. 개발 서버 실행: ```bash npm run dev -# 또는 -yarn dev -# 또는 -pnpm dev ``` -브라우저에서 [http://localhost:3000](http://localhost:3000)을 열어 결과를 확인하세요. +3. 프로덕션 빌드: + +```bash +npm run build +``` + +4. 프로덕션 서버 실행: + +```bash +npm run start +``` -## 사용된 기술 스택 +### 주요 설정 파일 -- [Next.js](https://nextjs.org/) - React 기반의 웹 애플리케이션 프레임워크 -- [React](https://reactjs.org/) - UI 구축을 위한 라이브러리 -- [TypeScript](https://www.typescriptlang.org/) - JavaScript의 정적 타입 검사 확장 -- [Tailwind CSS](https://tailwindcss.com/) - 유틸리티-퍼스트 CSS 프레임워크 -- [Jotai](https://jotai.org/) - React 상태 관리 라이브러리 +#### TailwindCSS (tailwind.config.ts) -## 주요 의존성 +- 커스텀 색상 및 스페이싱 +- Pretendard 폰트 설정 +- 반응형 디자인 지원 -- next: 14.2.12 -- react: ^18 -- react-dom: ^18 -- axios: ^1.7.7 -- date-fns: ^4.1.0 -- jotai: ^2.10.0 -- js-cookie: ^3.0.5 -- react-hook-form: ^7.53.0 -- react-router-dom: ^6.26.2 -- react-spinners: ^0.14.1 -- sharp: ^0.33.5 -- tailwind-merge: ^2.5.2 +#### Next.js (next.config.mjs) -## 개발 의존성 +- 이미지 최적화 설정 +- SVG 파일 처리 +- Node.js 모듈 설정 + +#### TypeScript (tsconfig.json) + +- 엄격한 타입 검사 +- 절대 경로 설정 +- Next.js 타입 지원 + +### 미들웨어 + +- 인증 보호 +- API 라우트 보호 +- 이미지 프록시 처리 + +## 폴더 구조 + +``` +src/ +├── components/ +│ ├── Layout/ +│ ├── UI/ +│ │ ├── community/ +│ │ ├── item/ +│ │ └── comment/ +├── hooks/ +├── pages/ +├── store/ +├── types/ +└── utils/ +``` -- typescript: ^5 -- eslint: ^8 -- eslint-config-next: 14.2.12 -- autoprefixer: ^10.4.20 -- postcss: ^8.4.47 -- tailwindcss: ^3.4.13 +## 개발 가이드 -## 스크립트 +### 컴포넌트 작성 -- `npm run dev`: 개발 서버 실행 -- `npm run build`: 프로덕션 빌드 -- `npm run start`: 프로덕션 모드로 서버 실행 -- `npm run lint`: 린트 검사 실행 -- `npm run clean`: 빌드 폴더 삭제 +- 재사용 가능한 UI 컴포넌트는 `components/UI` 폴더에 위치 +- 레이아웃 관련 컴포넌트는 `components/Layout` 폴더에 위치 +- Props 타입은 명시적으로 정의 -## Tailwind CSS 설정 +### 상태 관리 -Tailwind CSS는 커스텀 테마를 지원하며, 설정은 `tailwind.config.ts` 파일에서 관리됩니다. +- 전역 상태는 Jotai 사용 +- 서버 상태는 React Query 사용 +- 폼 상태는 React Hook Form 사용 -## Webpack 설정 +### 스타일링 -SVG 파일 처리를 위해 `@svgr/webpack`을 사용하며, 특정 Node.js 모듈(`fs`, `path`, `os`)은 브라우저에서 비활성화되어 있습니다. +- TailwindCSS 클래스 사용 +- 반응형 디자인 적용 +- tailwind-merge로 클래스 충돌 방지 -## 더 알아보기 +### 참고 문서 -- [Next.js 문서](https://nextjs.org/docs) - 기능과 API에 대해 알아보세요. -- [Next.js 학습](https://nextjs.org/learn) - 대화형 튜토리얼 체험. +- [Next.js 문서](https://nextjs.org/docs) +- [React 문서](https://reactjs.org/) +- [TailwindCSS 문서](https://tailwindcss.com/) ## 배포 -Next.js 앱을 배포하는 가장 쉬운 방법은 [Vercel 플랫폼](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)을 사용하는 것입니다. +이 프로젝트는 [Vercel Platform](https://vercel.com)을 통해 쉽게 배포할 수 있습니다. diff --git a/middleware.ts b/middleware.ts index 4a008cf7f..797c4cb9c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,36 +3,81 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { isValidImageUrl } from "@/utils/imageUtils"; // 이미지 확장자 검증 함수 가져오기 +// imageProxy 관련 로직을 별도 함수로 분리하여 가독성 향상 +const handleImageProxy = (url: URL) => { + const imageUrl = url.searchParams.get("url"); + + if (!imageUrl) { + return NextResponse.json({ error: "이미지 URL이 필요합니다." }, { status: 400 }); + } + + // 로컬 이미지 경로인 경우 프록시 처리하지 않음 + if (imageUrl.startsWith("/images/")) { + return NextResponse.next(); + } + + if (!isValidImageUrl(imageUrl)) { + return NextResponse.json({ error: "허용되지 않은 파일 형식입니다." }, { status: 400 }); + } + + return NextResponse.next(); +}; + // 미들웨어 함수 -export function middleware(request: NextRequest) { +export const middleware = (request: NextRequest) => { const url = request.nextUrl.clone(); - // /api/imageProxy 경로에 대한 요청만 처리 + // 이미지 프록시 요청 처리 if (url.pathname.startsWith("/api/imageProxy")) { - const imageUrl = url.searchParams.get("url"); - - // 이미지 URL이 없는 경우 에러 반환 - if (!imageUrl) { - return NextResponse.json( - { error: "이미지 URL이 필요합니다." }, - { status: 400 } - ); + return handleImageProxy(url); + } + + const accessToken = request.cookies.get("accessToken"); + const { pathname } = request.nextUrl; + + // 인증이 필요한 페이지 목록 (비공개 페이지) + const privatePages = ["/addArticle", "/addItem"]; + const isPrivatePage = privatePages.some((page) => pathname === page); + const isAuthPage = ["/login", "/signup"].includes(pathname); + + // API 라우트에 대한 처리 + if (pathname.startsWith("/api")) { + // /api/auth로 시작하는 경로는 모두 통과 + if (pathname.startsWith("/api/auth")) { + return NextResponse.next(); + } + + // refreshToken 엔드포인트는 별도 처리 + if (pathname === "/api/auth/refreshToken") { + return NextResponse.next(); } - // isValidImageUrl 함수 사용하여 확장자 검증 - if (!isValidImageUrl(imageUrl)) { - return NextResponse.json( - { error: "허용되지 않은 파일 형식입니다." }, - { status: 400 } - ); + // 토큰이 없는 경우 401 응답 + if (!accessToken) { + return NextResponse.json({ message: "인증이 필요합니다." }, { status: 401 }); } } - // 다른 요청에 대해 계속 처리 + // 로그인된 사용자가 로그인/회원가입 페이지 접근 시 홈으로 리다이렉트 + if (accessToken && isAuthPage) { + return NextResponse.redirect(new URL("/", request.url)); + } + + // 비공개 페이지에 대한 접근 제어 + if (!accessToken && isPrivatePage) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + // 다른 모든 요청 허용 return NextResponse.next(); -} +}; // 미들웨어가 적용될 경로 설정 export const config = { - matcher: "/api/imageProxy", + matcher: [ + // API 라우트 + "/api/:path*", + // 정적 파일을 제외한 모든 경로 + "/((?!_next|public|favicon.ico).*)", + ], }; diff --git a/package-lock.json b/package-lock.json index 93eb05df0..907324194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,36 +1,38 @@ { "name": "fe9-weekly-mission", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fe9-weekly-mission", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-query": "^5.59.20", "axios": "^1.7.7", - "cookie": "^0.7.2", + "cookie": "^1.0.1", "cors": "^2.8.5", "date-fns": "^4.1.0", "form-data": "^4.0.0", "formidable": "^3.5.1", "jotai": "^2.10.0", - "js-cookie": "^3.0.5", "next": "14.2.12", "react": "^18", "react-dom": "^18", - "react-hook-form": "^7.53.0", - "react-router-dom": "^6.26.2", + "react-hook-form": "^7.53.1", + "react-hot-toast": "^2.4.1", "react-spinners": "^0.14.1", "sharp": "^0.33.5", - "tailwind-merge": "^2.5.2" + "tailwind-merge": "^2.5.2", + "zod": "^3.23.8" }, "devDependencies": { "@svgr/webpack": "^8.1.0", + "@tanstack/react-query-devtools": "^5.59.20", "@types/cookie": "^0.6.0", "@types/cors": "^2.8.17", "@types/formidable": "^3.4.5", - "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^18.3.7", "@types/react-dom": "^18", @@ -2350,6 +2352,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3103,15 +3114,6 @@ "node": ">=14" } }, - "node_modules/@remix-run/router": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", - "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rushstack/eslint-patch": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", @@ -3405,6 +3407,61 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.59.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.20.tgz", + "integrity": "sha512-e8vw0lf7KwfGe1if4uPFhvZRWULqHjFcz3K8AebtieXvnMOz5FSzlZe3mTLlPuUBcydCnBRqYs2YJ5ys68wwLg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.59.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.59.20.tgz", + "integrity": "sha512-vxhuQ+8VV4YWQSFxQLsuM+dnEKRY7VeRzpNabFXdhEwsBYLrjXlF1pM38A8WyKNLqZy8JjyRO8oP4Wd/oKHwuQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.20.tgz", + "integrity": "sha512-Zly0egsK0tFdfSbh5/mapSa+Zfc3Et0Zkar7Wo5sQkFzWyB3p3uZWOHR2wrlAEEV2L953eLuDBtbgFvMYiLvUw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.59.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.59.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.59.20.tgz", + "integrity": "sha512-AL/eQS1NFZhwwzq2Bq9Gd8wTTH+XhPNOJlDFpzPMu9NC5CQVgA0J8lWrte/sXpdWNo5KA4hgHnEdImZsF4h6Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.59.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.59.20", + "react": "^18 || ^19" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -3442,13 +3499,6 @@ "@types/node": "*" } }, - "node_modules/@types/js-cookie": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", - "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -4390,12 +4440,12 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.1.tgz", + "integrity": "sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/core-js-compat": { @@ -4562,8 +4612,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "devOptional": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -5876,6 +5925,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -6476,15 +6534,6 @@ } } }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7523,9 +7572,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.53.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", - "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==", + "version": "7.53.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz", + "integrity": "sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -7538,43 +7587,27 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "node_modules/react-router": { - "version": "6.26.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", - "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==", + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.19.2" + "goober": "^2.1.10" }, "engines": { - "node": ">=14.0.0" + "node": ">=10" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=16", + "react-dom": ">=16" } }, - "node_modules/react-router-dom": { - "version": "6.26.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz", - "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.19.2", - "react-router": "6.26.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true }, "node_modules/react-spinners": { "version": "0.14.1", @@ -8979,6 +9012,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 2bd20d52d..b3887fa05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fe9-weekly-mission", - "version": "0.1.0", + "version": "1.0.0", "scripts": { "dev": "next dev", "build": "next build", @@ -9,29 +9,31 @@ "clean": "rimraf .next out" }, "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-query": "^5.59.20", "axios": "^1.7.7", - "cookie": "^0.7.2", + "cookie": "^1.0.1", "cors": "^2.8.5", "date-fns": "^4.1.0", "form-data": "^4.0.0", "formidable": "^3.5.1", "jotai": "^2.10.0", - "js-cookie": "^3.0.5", "next": "14.2.12", "react": "^18", "react-dom": "^18", - "react-hook-form": "^7.53.0", - "react-router-dom": "^6.26.2", + "react-hook-form": "^7.53.1", + "react-hot-toast": "^2.4.1", "react-spinners": "^0.14.1", "sharp": "^0.33.5", - "tailwind-merge": "^2.5.2" + "tailwind-merge": "^2.5.2", + "zod": "^3.23.8" }, "devDependencies": { "@svgr/webpack": "^8.1.0", + "@tanstack/react-query-devtools": "^5.59.20", "@types/cookie": "^0.6.0", "@types/cors": "^2.8.17", "@types/formidable": "^3.4.5", - "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^18.3.7", "@types/react-dom": "^18", diff --git a/src/api/articles/addArticle.ts b/src/api/articles/addArticle.ts deleted file mode 100644 index 4e45fcfe0..000000000 --- a/src/api/articles/addArticle.ts +++ /dev/null @@ -1,25 +0,0 @@ -import axios from "axios"; -import { Article, ArticleForm } from "@/types/article"; - -// 게시글 등록하기 -export async function addArticle( - articleForm: ArticleForm -): Promise
{ - try { - // 이미지가 없으면 빈 문자열로 설정 - if (!articleForm.image) articleForm.image = ""; - - const response = await axios.post("/api/articles/addArticle", articleForm); - - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.article; - } else { - console.error("게시글 등록 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addArticle 일반 에러:", error); - throw error; - } -} diff --git a/src/api/articles/addArticleLike.ts b/src/api/articles/addArticleLike.ts deleted file mode 100644 index e264f79bf..000000000 --- a/src/api/articles/addArticleLike.ts +++ /dev/null @@ -1,23 +0,0 @@ -import axios from "axios"; -import { ArticleDetail } from "@/types/article"; - -export async function addArticleLike( - articleId: number -): Promise { - try { - // Authorization 헤더에 JWT 토큰 추가 - const response = await axios.post("/api/articles/addArticleLike", { - articleId, - }); - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.articleDetail; - } else { - console.error("게시글 등록 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addArticle 일반 에러:", error); - throw error; - } -} diff --git a/src/api/articles/getArticleDetail.ts b/src/api/articles/getArticleDetail.ts deleted file mode 100644 index 895ce0ab7..000000000 --- a/src/api/articles/getArticleDetail.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axiosInstance from "../axiosConfig"; -import { AxiosError } from "axios"; -import { ArticleDetail } from "@/types/article"; - -export async function getArticleDetail( - articleId: number -): Promise { - if (!articleId) { - throw new Error("Invalid article ID"); - } - - try { - const response = await axiosInstance.get( - `/articles/${articleId}` - ); - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - // Axios 에러인 경우 처리 - console.error( - "getArticleDetail API 요청 에러:", - error.response?.data || error.message - ); - } else if (error instanceof Error) { - // 일반 에러 처리 - console.error("getArticleDetail 일반 에러:", error.message); - } else { - console.error("getArticleDetail 알 수 없는 오류:", error); - } - throw error; - } -} diff --git a/src/api/articles/getArticles.ts b/src/api/articles/getArticles.ts deleted file mode 100644 index 8b8b51aee..000000000 --- a/src/api/articles/getArticles.ts +++ /dev/null @@ -1,40 +0,0 @@ -import axiosInstance from "../axiosConfig"; -import { AxiosError } from "axios"; -import { ArticleListResponse, ArticleSortOption } from "@/types/article"; - -export async function getArticles({ - page, - pageSize, - orderBy, - keyword, -}: { - page: number; - pageSize: number; - orderBy: ArticleSortOption; - keyword?: string; -}): Promise { - try { - // keyword가 없으면 params에서 제외 - const params: Record = { page, pageSize, orderBy }; - if (keyword) params.keyword = keyword; - - const response = await axiosInstance.get("/articles", { - params, - }); - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - // Axios 에러인 경우 처리 - console.error( - "getArticles API 요청 에러:", - error.response?.data || error.message - ); - } else if (error instanceof Error) { - // 일반 에러 처리 - console.error("getArticles 일반 에러:", error.message); - } else { - console.error("getArticles 알 수 없는 오류:", error); - } - throw error; - } -} diff --git a/src/api/articles/removeArticle.ts b/src/api/articles/removeArticle.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/api/articles/removeArticleLike.ts b/src/api/articles/removeArticleLike.ts deleted file mode 100644 index b26057fbf..000000000 --- a/src/api/articles/removeArticleLike.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios from "axios"; -import { ArticleDetail } from "@/types/article"; - -export async function removeArticleLike( - articleId: number -): Promise { - try { - const response = await axios.delete("/api/articles/removeArticleLike", { - data: { articleId }, - }); - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.articleDetail; - } else { - console.error("게시글 좋아요 취소 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("removeArticleLike 일반 에러:", error); - throw error; - } -} diff --git a/src/api/articles/updateArticle.ts b/src/api/articles/updateArticle.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/api/auth/logout.ts b/src/api/auth/logout.ts deleted file mode 100644 index 5dc493a15..000000000 --- a/src/api/auth/logout.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from "axios"; - -export const logout = async (redirectToSignIn: () => void) => { - try { - const response = await axios.post("/api/auth/logout"); // Next.js API Route 호출 - - if (response.status !== 200) { - throw new Error("로그아웃 중 오류 발생 status: " + response.status); - } - - // 로그아웃 후 signIn 페이지로 리다이렉션 - redirectToSignIn(); - } catch (error) { - console.error("로그아웃 중 오류 발생:", error); - throw new Error("로그아웃 요청에 실패했습니다."); - } - - return null; -}; diff --git a/src/api/auth/refreshToken.ts b/src/api/auth/refreshToken.ts deleted file mode 100644 index 49b9f11ca..000000000 --- a/src/api/auth/refreshToken.ts +++ /dev/null @@ -1,21 +0,0 @@ -// src/api/auth/refreshToken.ts -import { User } from "@/types/auth"; -import axios from "axios"; - -export const refreshToken = async (): Promise => { - try { - const response = await axios.post("/api/auth/refreshToken"); // Next.js API 호출 - - if (response.status === 200) { - console.log("message: ", response.data.message); - } else { - console.error("message: ", response.data.message); - } - - return response.data.user; - } catch (error) { - console.error("토큰 갱신 요청에 실패했습니다:", error); - } - - return null; -}; diff --git a/src/api/auth/signIn.ts b/src/api/auth/signIn.ts deleted file mode 100644 index 5e2ad0e34..000000000 --- a/src/api/auth/signIn.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from "axios"; -import { LoginFormValues, SignInResponse } from "@/types/auth"; - -export const signIn = async ( - formData: LoginFormValues -): Promise => { - try { - const response = await axios.post( - "/api/auth/signIn", - formData - ); - return response.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - return error.response.data as SignInResponse; - } - throw error; - } -}; diff --git a/src/api/auth/signUp.ts b/src/api/auth/signUp.ts deleted file mode 100644 index 3c3353244..000000000 --- a/src/api/auth/signUp.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from "axios"; -import { SignupFormValues, SignUpResponse } from "@/types/auth"; - -export const signUp = async ( - formData: SignupFormValues -): Promise => { - try { - const response = await axios.post( - "/api/auth/signUp", - formData - ); - return response.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - return error.response.data as SignUpResponse; - } - throw error; - } -}; diff --git a/src/api/axiosConfig.ts b/src/api/axiosConfig.ts deleted file mode 100644 index 56cb638d3..000000000 --- a/src/api/axiosConfig.ts +++ /dev/null @@ -1,14 +0,0 @@ -// src/api/axiosConfig.js -import Axios from "axios"; - -export const API_URL = "https://panda-market-api.vercel.app"; - -const axiosInstance = Axios.create({ - baseURL: API_URL, - timeout: 5000, - headers: { - "Content-Type": "application/json", - }, -}); - -export default axiosInstance; diff --git a/src/api/comments/addArticleComment.ts b/src/api/comments/addArticleComment.ts deleted file mode 100644 index 6b2ac7e86..000000000 --- a/src/api/comments/addArticleComment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import axios from "axios"; -import { Comment } from "@/types/comment"; - -// 게시글 댓글 등록하기 -export async function addArticleComment({ - articleId, - content, -}: { - articleId: number; - content: string; -}): Promise { - try { - const response = await axios.post("/api/comments/addArticleComment", { - articleId, - content, - }); - - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.comment; - } else { - console.error("게시글 댓글 등록 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addArticleComment 일반 에러:", error); - throw error; - } -} diff --git a/src/api/comments/addProductComment.ts b/src/api/comments/addProductComment.ts deleted file mode 100644 index de021708e..000000000 --- a/src/api/comments/addProductComment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import axios from "axios"; -import { Comment } from "@/types/comment"; - -// 게시글 댓글 등록하기 -export async function addProductComment({ - productId, - content, -}: { - productId: number; - content: string; -}): Promise { - try { - const response = await axios.post("/api/comments/addProductComment", { - productId, - content, - }); - - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.comment; - } else { - console.error("상품 댓글 등록 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addProductComment 일반 에러:", error); - throw error; - } -} diff --git a/src/api/comments/getArticleComments.ts b/src/api/comments/getArticleComments.ts deleted file mode 100644 index bfa101e20..000000000 --- a/src/api/comments/getArticleComments.ts +++ /dev/null @@ -1,45 +0,0 @@ -import axiosInstance from "../axiosConfig"; -import { AxiosError } from "axios"; -import { CommentListResponse } from "@/types/comment"; - -export async function getArticleComments( - articleId: number, - { - limit, - cursor, - }: { - limit: number; - cursor?: number | null; - } -): Promise { - if (!articleId) { - throw new Error("Invalid article ID"); - } - - try { - const response = await axiosInstance.get( - `/articles/${articleId}/comments`, - { - params: { - limit, - cursor, - }, - } - ); - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - // Axios 에러인 경우 처리 - console.error( - "getArticleComments API 요청 에러:", - error.response?.data || error.message - ); - } else if (error instanceof Error) { - // 일반 에러 처리 - console.error("getArticleComments 일반 에러:", error.message); - } else { - console.error("getArticleComments 알 수 없는 오류:", error); - } - throw error; - } -} diff --git a/src/api/comments/getProductComments.ts b/src/api/comments/getProductComments.ts deleted file mode 100644 index 76594218e..000000000 --- a/src/api/comments/getProductComments.ts +++ /dev/null @@ -1,45 +0,0 @@ -import axiosInstance from "../axiosConfig"; -import { AxiosError } from "axios"; -import { CommentListResponse } from "@/types/comment"; - -export async function getProductComments( - productId: number, - { - limit, - cursor, - }: { - limit: number; - cursor?: number | null; - } -): Promise { - if (!productId) { - throw new Error("유효하지 않은 상품 ID입니다."); - } - - try { - const response = await axiosInstance.get( - `/products/${productId}/comments`, - { - params: { - limit, - cursor, // cursor는 선택 사항이므로 존재할 때만 추가됨 - }, - } - ); - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - // Axios 에러인 경우 처리 - console.error( - "getProductComments API 요청 에러:", - error.response?.data || error.message - ); - } else if (error instanceof Error) { - // 일반 에러 처리 - console.error("getProductComments 일반 에러:", error.message); - } else { - console.error("getProductComments 알 수 없는 오류:", error); - } - throw error; - } -} diff --git a/src/api/comments/removeComment.ts b/src/api/comments/removeComment.ts deleted file mode 100644 index 941c0752d..000000000 --- a/src/api/comments/removeComment.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios from "axios"; - -export async function removeComment(commentId: number): Promise { - try { - // Authorization 헤더에 JWT 토큰 추가 - const response = await axios.delete("/api/comments/removeComment", { - params: { - commentId, - }, - }); - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.id; - } else { - console.error("게시글 댓글 삭제 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("removeArticleLike 일반 에러:", error); - throw error; - } -} diff --git a/src/api/comments/updateComment.ts b/src/api/comments/updateComment.ts deleted file mode 100644 index f4147f064..000000000 --- a/src/api/comments/updateComment.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from "axios"; -import { Comment } from "@/types/comment"; - -// 게시글 댓글 수정하기 -export async function updateComment( - commentId: number -): Promise { - try { - const response = await axios.patch("/api/comments/updateComment", { - commentId, - }); - - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.comment; - } else { - console.error("게시글 댓글 수정 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addArticleComment 일반 에러:", error); - throw error; - } -} diff --git a/src/api/products/addProduct.ts b/src/api/products/addProduct.ts deleted file mode 100644 index 936c2a73a..000000000 --- a/src/api/products/addProduct.ts +++ /dev/null @@ -1,25 +0,0 @@ -import axios from "axios"; -import { Product, ProductForm } from "@/types/product"; - -// 상품 등록하기 -export async function addProduct( - productForm: ProductForm -): Promise { - try { - // 이미지가 없으면 빈 문자열로 설정 - if (!productForm.images[0]) productForm.images[0] = ""; - - const response = await axios.post("/api/products/addProduct", productForm); - - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.product; - } else { - console.error("상품 등록 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addProduct 일반 에러:", error); - throw error; - } -} diff --git a/src/api/products/addProductFavorite.ts b/src/api/products/addProductFavorite.ts deleted file mode 100644 index d9372dfaa..000000000 --- a/src/api/products/addProductFavorite.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios from "axios"; -import { ProductDetail } from "@/types/product"; - -export async function addProductFavorite( - productId: number -): Promise { - try { - const response = await axios.post("/api/products/addProductFavorite", { - productId, - }); - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.productDetail; - } else { - console.error("상품 좋아요 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("addProductFavorite 일반 에러:", error); - throw error; - } -} diff --git a/src/api/products/getProductDetail.ts b/src/api/products/getProductDetail.ts deleted file mode 100644 index cb5eb1c1e..000000000 --- a/src/api/products/getProductDetail.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axiosInstance from "../axiosConfig"; -import { AxiosError } from "axios"; -import { ProductDetail } from "@/types/product"; - -export async function getProductDetail( - productId: number -): Promise { - if (!productId) { - throw new Error("Invalid product ID"); - } - - try { - const response = await axiosInstance.get( - `/products/${productId}` - ); - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - // Axios 에러인 경우 처리 - console.error( - "getProductDetail API 요청 에러:", - error.response?.data || error.message - ); - } else if (error instanceof Error) { - // 일반 에러 처리 - console.error("getProductDetail 일반 에러:", error.message); - } else { - console.error("getProductDetail 알 수 없는 오류:", error); - } - throw error; - } -} diff --git a/src/api/products/getProducts.ts b/src/api/products/getProducts.ts deleted file mode 100644 index 346616125..000000000 --- a/src/api/products/getProducts.ts +++ /dev/null @@ -1,40 +0,0 @@ -import axiosInstance from "../axiosConfig"; -import { AxiosError } from "axios"; -import { ProductListResponse, ProductSortOption } from "@/types/product"; - -export async function getProducts({ - page, - pageSize, - orderBy, - keyword, -}: { - page: number; - pageSize: number; - orderBy: ProductSortOption; - keyword?: string; -}): Promise { - try { - // keyword가 없으면 params에서 제외 - const params: Record = { page, pageSize, orderBy }; - if (keyword) params.keyword = keyword; - - const response = await axiosInstance.get("/products", { - params, - }); - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - // Axios 에러인 경우 처리 - console.error( - "getProducts API 요청 에러:", - error.response?.data || error.message - ); - } else if (error instanceof Error) { - // 일반 에러 처리 - console.error("getProducts 일반 에러:", error.message); - } else { - console.error("getProducts 알 수 없는 오류:", error); - } - throw error; - } -} diff --git a/src/api/products/removeProduct.ts b/src/api/products/removeProduct.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/api/products/removeProductFavorite.ts b/src/api/products/removeProductFavorite.ts deleted file mode 100644 index 4ca8b6d82..000000000 --- a/src/api/products/removeProductFavorite.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios from "axios"; -import { ProductDetail } from "@/types/product"; - -export async function removeProductFavorite( - productId: number -): Promise { - try { - const response = await axios.delete(`/api/products/removeProductFavorite`, { - data: { productId }, - }); - if (response.status === 200) { - console.log("message: ", response.data.message); - return response.data.productDetail; - } else { - console.error("상품 좋아요 취소 실패:", response.data.message); - throw new Error(response.data.message); - } - } catch (error) { - console.error("removeProductFavorite 일반 에러:", error); - throw error; - } -} diff --git a/src/api/products/updateProduct.ts b/src/api/products/updateProduct.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/Layout/ClientLayout.tsx b/src/components/Layout/ClientLayout.tsx index 61074ada0..96a75f3fd 100644 --- a/src/components/Layout/ClientLayout.tsx +++ b/src/components/Layout/ClientLayout.tsx @@ -1,25 +1,17 @@ // components/Layout/ClientLayout.tsx -import { Provider, useAtom } from "jotai"; -import { userAtom } from "@/store/authAtoms"; -import { User } from "@/types/auth"; +import { Provider } from "jotai"; import Header from "./Header"; function ClientLayoutContent({ children }: { children: React.ReactNode }) { - const [user] = useAtom(userAtom); - return (
-
+
{children}
); } -export default function ClientLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function ClientLayout({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index b94913951..bc5469dba 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -3,11 +3,9 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/router"; import Image from "next/image"; import Link from "next/link"; -import axios from "axios"; -import { User } from "@/types/auth"; -import { removeAllAuthCookies } from "@/utils/cookie"; -import AlertModal from "@/components/UI/modal/AlertModal"; -import { useAtom } from "jotai"; +import toast from "react-hot-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { useAtomValue } from "jotai"; import { userAtom } from "@/store/authAtoms"; // public 폴더 경로 문자열로 대체 @@ -16,64 +14,39 @@ const LOGO_MD = "/images/logo/logo_md.png"; const LOGO_LG = "/images/logo/logo_lg.png"; const DEFAULT_AVATAR = "/images/ui/ic_profile-32.png"; -interface HeaderProps { - user: User | null; -} - -export default function Header({ user }: HeaderProps) { +export default function Header() { const router = useRouter(); - const [isOpen, setIsOpen] = useState(false); // 드롭다운 상태 - const [isAlertOpen, setIsAlertOpen] = useState(false); // AlertModal 상태 - const [alertMessage, setAlertMessage] = useState(""); // AlertModal 메시지 상태 - const [, setUser] = useAtom(userAtom); + const [isOpen, setIsOpen] = useState(false); + const { logout, refetchUser } = useAuth(); + const user = useAtomValue(userAtom); + // 컴포넌트 마운트 시 인증 상태 확인 useEffect(() => { - async function checkAuthStatus() { - try { - const response = await axios.post("/api/auth/refreshToken"); - if (response.status === 200 && response.data.isLogin) { - setUser(response.data.user); - } else { - setUser(null); - } - } catch (error) { - console.error("인증 상태 확인 중 오류 발생:", error); - setUser(null); - } - } - - if (!user) { - checkAuthStatus(); - } - }, [setUser, user]); + refetchUser(); + }, [refetchUser]); const handleLogout = async () => { + if (!user || !logout) return; + try { - await removeAllAuthCookies(); - setUser(null); // 사용자 상태를 null로 설정 - setIsOpen(false); // 드롭다운 메뉴 닫기 - router.push("/"); + await logout(); + setIsOpen(false); } catch (error) { console.error("로그아웃 중 오류 발생:", error); - setAlertMessage("로그아웃 중 오류가 발생했습니다. 다시 시도해 주세요."); - setIsAlertOpen(true); + toast.error("로그아웃 중 오류가 발생했습니다. 다시 시도해 주세요."); } }; const toggleDropdown = () => { - setIsOpen(!isOpen); // 드롭다운 상태를 토글 - }; - - // AlertModal 닫기 - const handleCloseAlert = () => { - setIsAlertOpen(false); // 모달 닫기 + setIsOpen(!isOpen); }; + // 드롭다운 외부 클릭 처리 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if (isOpen && !target.closest(".user-avatar")) { - setIsOpen(false); // 다른 곳을 클릭하면 드롭다운 닫기 + setIsOpen(false); } }; @@ -81,26 +54,21 @@ export default function Header({ user }: HeaderProps) { return () => { document.removeEventListener("click", handleClickOutside); }; - }, [isOpen]); // 의존성 배열에 isOpen 추가 + }, [isOpen]); - // 자유게시판 및 중고마켓 메뉴 활성화 여부 설정 - const isCommunityActive = - router.pathname.startsWith("/community") || - router.pathname === "/addArticle"; - const isItemsActive = - router.pathname.startsWith("/items") || router.pathname === "/addItem"; + // 메뉴 활성화 상태 확인 + const isCommunityActive = router.pathname.startsWith("/community") || router.pathname === "/addArticle"; + const isItemsActive = router.pathname.startsWith("/items") || router.pathname === "/addItem"; return ( <>
- {/* 로고 이미지 변경 */}
- {/* 작은 화면용 로고 */}
- {/* 중간 화면용 로고 */}
- {/* 큰 화면용 로고 */}
- {user?.id ? ( + {user ? (
User Avatar - {isOpen && ( // 드롭다운 열기 + {isOpen && (
- {user.nickname ? ( -
{user.nickname}
- ) : ( -
사용자
- )} -
)}
) : ( - + 로그인 )}
- - {/* AlertModal 컴포넌트 */} - ); } diff --git a/src/components/UI/DropdownMenu.tsx b/src/components/UI/DropdownMenu.tsx index 3f4099fbf..b685c5380 100644 --- a/src/components/UI/DropdownMenu.tsx +++ b/src/components/UI/DropdownMenu.tsx @@ -1,8 +1,8 @@ // src/components/UI/DropdownMenu.tsx import React, { useState } from "react"; import Image from "next/image"; -import { ProductSortOption } from "@/types/product"; -import { ArticleSortOption } from "@/types/article"; +import { ProductSortOption } from "@/constants/ProductSortOption"; +import { ArticleSortOption } from "@/constants/ArticleSortOption"; // public 폴더 경로 문자열로 대체 const ARROW_DOWN_ICON = "/images/icons/ic_arrow_down.png"; @@ -48,9 +48,7 @@ const DropdownMenu = ({ style={{ border: "1px solid #E5E7EB" }} onClick={toggleDropdown} > - - {getOptionText(selectedOption)} - + {getOptionText(selectedOption)}
{" "} {/* 부모 요소에 크기 지정 */} @@ -74,11 +72,7 @@ const DropdownMenu = ({
- handleOptionSelect( - (type === "product" ? "favorite" : "like") as T - ) - } + onClick={() => handleOptionSelect((type === "product" ? "favorite" : "like") as T)} > 인기순
diff --git a/src/components/UI/comment/ArticleCommentSection.tsx b/src/components/UI/comment/ArticleCommentSection.tsx index 641ebf4a4..d660e2403 100644 --- a/src/components/UI/comment/ArticleCommentSection.tsx +++ b/src/components/UI/comment/ArticleCommentSection.tsx @@ -1,10 +1,10 @@ // src/components/UI/comment/ArticleCommentSection.tsx import React, { ChangeEvent, useState } from "react"; -import { addArticleComment } from "@/api/comments/addArticleComment"; // API 함수 임포트 import CommentThread from "./ArticleCommentThread"; -import AlertModal from "@/components/UI/modal/AlertModal"; // AlertModal 임포트 +import AlertModal from "@/components/UI/modal/AlertModal"; import { useAtom } from "jotai"; import { userAtom } from "@/store/authAtoms"; +import { useComment } from "@/hooks/useComment"; const COMMENT_PLACEHOLDER = "댓글을 입력해주세요."; @@ -13,52 +13,42 @@ interface ArticleCommentSectionProps { } const ArticleCommentSection = ({ articleId }: ArticleCommentSectionProps) => { - const [comment, setComment] = useState(""); // 댓글 입력 상태 - const [loading, setLoading] = useState(false); // 버튼 로딩 상태 처리 - const [isAlertOpen, setIsAlertOpen] = useState(false); // AlertModal 상태 - const [alertMessage, setAlertMessage] = useState(""); // AlertModal 메시지 상태 - const [refreshComments, setRefreshComments] = useState(0); // CommentThread 리렌더링 트리거 상태 + const [comment, setComment] = useState(""); + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertMessage, setAlertMessage] = useState(""); const [user] = useAtom(userAtom); - // 입력 필드 값 변경 시 상태 업데이트 + + const { addArticleComment, isLoading } = useComment(); + const handleInputChange = (e: ChangeEvent) => { setComment(e.target.value); }; - // 댓글 등록 버튼 클릭 시 호출 const handlePostComment = async () => { if (!comment.trim()) { - // 빈 댓글일 경우 AlertModal 띄우기 setAlertMessage("댓글을 입력해주세요."); setIsAlertOpen(true); return; } if (!user) { - // 토큰이 없을 경우 AlertModal 띄우기 setAlertMessage("로그인이 필요합니다."); setIsAlertOpen(true); return; } try { - setLoading(true); // 로딩 상태 설정 - // API를 호출하여 댓글 등록 - await addArticleComment({ content: comment.trim(), articleId }); - // 댓글 등록 성공 시 입력 필드 초기화 및 CommentThread 리렌더링 트리거 - setComment(""); // 입력 필드 초기화 - setRefreshComments((prev) => prev + 1); // CommentThread 리렌더링 트리거 + await addArticleComment({ articleId, content: comment.trim() }); + setComment(""); } catch (error) { console.error("댓글 등록 실패:", error); setAlertMessage("댓글 등록에 실패했습니다. 다시 시도해주세요."); - setIsAlertOpen(true); // 실패 메시지 모달 띄우기 - } finally { - setLoading(false); // 로딩 상태 해제 + setIsAlertOpen(true); } }; - // AlertModal 닫기 const handleCloseAlert = () => { - setIsAlertOpen(false); // 모달 닫기 + setIsAlertOpen(false); }; return ( @@ -76,21 +66,14 @@ const ArticleCommentSection = ({ articleId }: ArticleCommentSectionProps) => { - {/* 댓글 쓰레드 컴포넌트 */} - - - {/* AlertModal 컴포넌트 */} - + + ); }; diff --git a/src/components/UI/comment/ArticleCommentThread.tsx b/src/components/UI/comment/ArticleCommentThread.tsx index e92dbabac..839dcb8ae 100644 --- a/src/components/UI/comment/ArticleCommentThread.tsx +++ b/src/components/UI/comment/ArticleCommentThread.tsx @@ -1,48 +1,140 @@ // src/components/UI/comment/ArticleCommentThread.tsx -import { useEffect, useState, useRef, useCallback } from "react"; +import { useRef, useCallback, useEffect, useState } from "react"; import Image from "next/image"; -import { getArticleComments } from "@/api/comments/getArticleComments"; import { formatUpdatedAt } from "@/utils/dateUtils"; -import { Comment, CommentListResponse } from "@/types/comment"; +import { Comment } from "@/types/comment"; import EmptyComment from "../EmptyComment"; -import { isValidImageUrl } from "@/utils/imageUtils"; // 이미지 유효성 검사 함수 가져오기 +import { isValidImageUrl } from "@/utils/imageUtils"; +import { useComment } from "@/hooks/useComment"; +import { useAtom } from "jotai"; +import { userAtom } from "@/store/authAtoms"; +import ConfirmModal from "../modal/ConfirmModal"; +import AlertModal from "../modal/AlertModal"; -// public 폴더 경로 문자열로 대체 const KEBAB_ICON = "/images/icons/ic_kebab.png"; const DEFAULT_PROFILE_IMAGE = "/images/ui/ic_profile-40.png"; -// 댓글 하나를 나타내는 컴포넌트 interface CommentItemProps { item: Comment; + onCommentUpdate: (commentId: number, content: string) => Promise; + onCommentDelete: (commentId: number) => Promise; } -const CommentItem = ({ item }: CommentItemProps) => { +const CommentItem = ({ item, onCommentUpdate, onCommentDelete }: CommentItemProps) => { + const [user] = useAtom(userAtom); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editContent, setEditContent] = useState(item.content); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertMessage, setAlertMessage] = useState(""); + const authorInfo = item.writer; - const formattedTimestamp = formatUpdatedAt(item.updatedAt); // 시간 포맷팅 + const formattedTimestamp = formatUpdatedAt(item.updatedAt); + const isOwner = user?.id === authorInfo.id; - // 작성자의 프로필 이미지가 유효한지 확인 후 이미지 URL 설정 const imageUrl = authorInfo.image && isValidImageUrl(authorInfo.image) ? `/api/imageProxy?url=${encodeURIComponent(authorInfo.image)}` : DEFAULT_PROFILE_IMAGE; + // 드롭다운 외부 클릭 처리 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (isDropdownOpen && !target.closest(".kebab-menu")) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener("click", handleClickOutside); + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, [isDropdownOpen]); + + const handleEdit = () => { + setIsEditing(true); + setIsDropdownOpen(false); + }; + + const handleDelete = () => { + setIsDropdownOpen(false); + setIsConfirmOpen(true); + }; + + const handleUpdateSubmit = async () => { + try { + await onCommentUpdate(item.id, editContent); + setIsEditing(false); + } catch (error) { + console.error("댓글 수정 실패:", error); + setAlertMessage("댓글 수정에 실패했습니다."); + setIsAlertOpen(true); + } + }; + + const handleDeleteConfirm = async () => { + try { + await onCommentDelete(item.id); + setIsConfirmOpen(false); + } catch (error) { + console.error("댓글 삭제 실패:", error); + setAlertMessage("댓글 삭제에 실패했습니다."); + setIsAlertOpen(true); + } + }; + return ( <>
- {/* 케밥 버튼 (추후 기능 추가 예정) */} - - -

{item.content}

+ {isOwner && ( +
+ + {isDropdownOpen && ( +
+ + +
+ )} +
+ )} + + {isEditing ? ( +
+