diff --git a/README.md b/README.md index 1d9eb619..aa473b6f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # ✨ Preview +
@@ -8,117 +9,93 @@

✨ Preview에서 면접 연습 시작하자! ✨

-### 배포 -[**Preview 바로가기**](https://boostcamp-preview.kro.kr) +
-## 👋 팀원 소개 +
-|김찬우|서정우|송수민|이승윤| -|:---:|:---:|:---:|:---:| -||||| -|WEB BE|WEB FE|WEB BE|WEB FE| +[노션 홈](https://alpine-tiglon-9f0.notion.site/PREVIEW-HOME-12d696f85d1f805b9787e26374b3d209?pvs=4) | [프로젝트](https://github.com/orgs/boostcampwm-2024/projects/51) | [피그마](https://www.figma.com/file/YunC4M9LWDRROD2pyXL8jE/boostcamp-booskit) -
+[위키](https://github.com/boostcampwm-2024/web27-Preview/wiki) | [배포 링크](https://boostcamp-preview.kro.kr) -> 🐧 **김찬우** -> -> '함께 자라는 개발도 잘하는 사람'이 되고 싶은 그저 프로그래밍이 재밌는 사람입니다. 펭귄처럼 바보라도 당당하게 호기심 있게 되고 싶습니다! +![Hits](https://hits.sh/github.com/boostcampwm-2024/web27-Preview.svg?style=flat-square) -> 💣 **서정우** -> -> 멈추지 않는 기술의 변화를 즐기는 개발자가 되고 싶은 서정우입니다! +
-> 🐬 **송수민** -> -> 안녕하세요! 저는 수영장에 가거나 차 마시는 것을 좋아하는 백엔드 개발자 송수민입니다. +## ☃️ 프로젝트 소개 -> 🦄 **이승윤** -> -> 안녕하십니까마귀. 저는 즐겁게 개발하는 걸 좋아합니다! 🎉 - -
+> "오프라인으로 모이기 쉽지 않은 면접 스터디!" -## 👥 협업 -### 브랜치 구조 -- git flow -### 커밋 규칙 -**Udacity 스타일 가이드** - -| 타입 | 상황 | -| --- | --- | -| feat | 새로운 기능을 추가하였을 때 | -| fix | 버그를 수정하였을 때 | -| docs | README 등 문서 내용을 변경하였을 때 | -| style | 들여쓰기, 세미콜론 등을 변경하였을 때 | -| refactor | 코드 리팩토링을 했을 때 (기능 변경 X) | -| test | test코드의 작성 및 수정이 이루어졌을 때 | -| chore | 외부 라이브러리 임포트 등의 작업을 완료했을 때 | +> "실제 소통이 아쉬운 AI 면접 서비스!" -
+나의 성공적인 면접 스터디, **PREVIEW**로 시작해보세요! -``` -타입: 작업한 내용 제목 +**실시간 화상 스터디**로 장소에 구애받지 않고 **면접 연습**을 시작할 수 있습니다. -- (선택) 작업한 내용에 대한 설명 -``` +## 📣 핵심 기능 -
+### WebRTC 기반 실시간 화상 면접 -## 📝 그라운드 룰 -### 1️⃣ 기록 먼저하기 -- 각자가 찾은 내용이나 문제 해결 과정 등 대부분을 기록하기 -- 개발 시 개발 일지를 작성해서, 개발 중 문제를 찾고 상대방이 리뷰하기 쉽도록하기 +- 실제 면접과 비슷한 화상 환경에서 연습 +- 다자간 화상 연결로 실제 면접 분위기 조성 +- 즉각적인 피드백 교환 가능 -### 2️⃣ PR과 리뷰 -- PR의 merge는 모두가 approve해야 merge 되도록 하기 -- 모든 사람의 PR에 리뷰나 코멘트를 남기고 확인했음을 표시하기 (특별한 코멘트가 없다면 이모지라도 남기기) -- 프론트엔드/백엔드를 나누지 않고 모든 개발 상황에 대해 이해할 수 있게 노력하기 +### 스터디 채널 -### 3️⃣ 데일리 스크럼 -- 매일 매일 TMI를 공유하기 -- 자신이 한 일과 계획 그리고 생겼던 문제 등에 대해 공유하기 +- 함께 면접을 연습하고 싶은 동료들과 스터디 채널 개설 +- 공개/비공개 채널 지원 -### 4️⃣ 의견 충돌 조율 -- 의견 충돌이 발생하면 각자 자신의 의견에 대해 충분히 말해보기 -- 다수결로 정해보기 -- 다수결로 정해지지 않는다면, 슈퍼패스를 한 번씩 써볼 수 있게 하기 +### 맞춤형 질문지 생성 및 공유 -### 5️⃣ 매주 회고하기 -- KPT 방식으로 매주 부족했던 것과 좋았던 점을 회고하며, 다음 주에 더 나은 방식으로 나아갈 수 있게 노력하기 +- 면접 질문지 생성 및 공유 +- 면접 질문지 공유로 다양한 질문에 대비 +- 사용량에 따른 인기 질문지 제공 -### 6️⃣ 허들로 소통하기 -- 함께 개발하거나 학습할 일이 있을 때 허들에서 함께하기 +### 면접 스터디 기능 -
+- 실제 면접처럼 질문을 하나씩 받고 서로 대답해보는 스터디 기능 +- 면접 질문에 대한 피드백을 주고 받을 수 있음 +- 면접 스터디 결과를 기록하고 공유할 수 있음 ## 🧩 설계 -### 기획 -### 디자인 +architecture + +## 🛠 기술 스택 -### 시스템 구조 +| Category | Stack | +|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Frontend | ![React](https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black) ![React Query](https://img.shields.io/badge/React_Query-FF4154?style=for-the-badge&logo=react-query&logoColor=white) ![React Router](https://img.shields.io/badge/React_Router-CA4245?style=for-the-badge&logo=react-router&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white) ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwind-css&logoColor=white) ![Zustand](https://img.shields.io/badge/Zustand-000000?style=for-the-badge) | +| Backend | ![Nginx](https://img.shields.io/badge/nginx-%23009639.svg?style=for-the-badge&logo=nginx&logoColor=white) ![NestJS](https://img.shields.io/badge/NestJS-E0234E?style=for-the-badge&logo=nestjs&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) ![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-4479A1?style=for-the-badge&logo=mysql&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-339933?style=for-the-badge&logo=node.js&logoColor=white) ![TypeORM](https://img.shields.io/badge/TypeORM-E83524?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGQ9Ik0yNDEuMzUyIDI1LjcyN2wtNTQuOTQtMTkuMjhBMjcuNTE3IDI3LjUxNyAwIDAgMCAxNjcuMjE1IDRIMTA3LjE4YTI3LjQ4NCAyNy40ODQgMCAwIDAtMTkuMTk3IDIuNDQ3TDMzLjAzOCAyNS43MjdDMTguNDc3IDMyLjQxMyAxMCA0Ny40NTEgMTAgNjMuOTc3djkzLjU1NmMwIDIzLjcxIDEzLjA3IDQ1LjUyNiAzNC4wMjMgNTYuNTU3bDYzLjE5NyAzMy4xOTVjMTEuOTUgNi4yNzcgMjYuMDM3IDcuOTc0IDM5LjE1OCA0Ljc4NGw3Ni4wMTEtMTguNTRDMjQxLjg2NSAyMjYuOTc4IDI1NSAyMDguOTg4IDI1NSAxODguNDcyVjYzLjk3N2MwLTE2LjUyNi04LjQ3OC0zMS41NjQtMjMuMDM4LTM4LjI1Wm0tMjkuMDYgNjkuNzk2YTguMDY3IDguMDY3IDAgMCAxLTguNDc3IDEzLjY2OGwtOC40NzctNS4yNjJ2NjIuNTgzYTguMDY0IDguMDY0IDAgMCAxLTQuMDkyIDcuMDA4bC00OS45NjcgMjkuODk4Yy0yLjUwMyAxLjQ5Ny01LjU4NSAxLjUzMS04LjEyMS4wODlMODQuMTkgMTc0LjA1OGE4LjA2NSA4LjA2NSAwIDAgMS00LjA5NC03LjAwOFY5OC43MDhhOC4wNjQgOC4wNjQgMCAwIDEgMy45NDktNi45MjlsNDAuMzU1LTI1LjE1N2E4LjA2NCA4LjA2NCAwIDAgMSA4LjQzMS0uMDM0bDQwLjA5NSAyNC40ODRhOC4wNjQgOC4wNjQgMCAwIDEgNC4wOTMgNi45OTd2OS40NzJsOC40NzctNS4wOGE4LjA2NCA4LjA2NCAwIDAgMSA4LjQ3Ny0uMTI3bDE4LjMxNSAxMS4xODlaIi8+PC9zdmc+&logoColor=white) | +| Common | ![WebRTC](https://img.shields.io/badge/WebRTC-333333?style=for-the-badge&logo=webrtc&logoColor=white) ![pnpm](https://img.shields.io/badge/pnpm-F69220?style=for-the-badge&logo=pnpm&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-4B32C3?style=for-the-badge&logo=eslint&logoColor=white) ![Husky](https://img.shields.io/badge/Husky-000000?style=for-the-badge&logo=husky&logoColor=white) ![Prettier](https://img.shields.io/badge/Prettier-F7B93E?style=for-the-badge&logo=prettier&logoColor=black) | +| DevOps | ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![NCP](https://img.shields.io/badge/Naver_Cloud-03C75A?style=for-the-badge&logo=naver&logoColor=white) | +| CI/CD | ![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-2088FF?style=for-the-badge&logo=github-actions&logoColor=white) | +| Etc | ![Figma](https://img.shields.io/badge/Figma-F24E1E?style=for-the-badge&logo=figma&logoColor=white) ![Notion](https://img.shields.io/badge/Notion-000000?style=for-the-badge&logo=notion&logoColor=white) |
-## 📁 문서 +## 👋 팀원 소개 -### 팀 노션 워크스페이스 -- [노션 링크](https://alpine-tiglon-9f0.notion.site/PREVIEW-HOME-12d696f85d1f805b9787e26374b3d209?pvs=4) +| [김찬우](https://github.com/blu3piece) | [서정우](https://github.com/ShipFriend0516) | [송수민](https://github.com/twalla26) | [이승윤](https://github.com/yiseungyun) | +|:--------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------:| +| | | | | +| WEB BE | WEB FE | WEB BE | WEB FE | -### 회의록 -- [1주차 회의록](https://github.com/boostcampwm-2024/web27-boostproject/wiki/1%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EC%9D%98%EB%A1%9D) -- [2주차 회의록](https://github.com/boostcampwm-2024/web27-boostproject/wiki/2%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EC%9D%98%EB%A1%9D) +
+> 🐧 **김찬우** +> +> '함께 자라는 개발도 잘하는 사람'이 되고 싶은 그저 프로그래밍이 재밌는 사람입니다. 펭귄처럼 바보라도 당당하게 호기심 있게 되고 싶습니다! -### 회고 -- [팀회고](https://alpine-tiglon-9f0.notion.site/13a138b3a6894de39933e51b28807050?pvs=4) +> 💣 **서정우** +> +> 멈추지 않는 기술의 변화를 즐기는 개발자가 되고 싶은 서정우입니다! -### 데일리 노트 -- [김찬우](https://alpine-tiglon-9f0.notion.site/a508fc384103499e93e24d08853823fc?v=df366c8d70f7482bad03a674a45c7606&pvs=74) -- [송수민](https://alpine-tiglon-9f0.notion.site/887afedf1a5b4e93861b97c15ed38611?v=5012a5fb547344e79b460503fd146ad3&pvs=4) -- [서정우](https://alpine-tiglon-9f0.notion.site/4b8e2b6b9a554c13a2cbb69671d7bd29?v=78b3d26348cf40bcb1c09e0d05836dae&pvs=4) -- [이승윤](https://alpine-tiglon-9f0.notion.site/31e9cfd20c2f4e50a64abde07444b23d?v=cfc1e71581ce469b814c5b34e1803cff&pvs=4) +> 🐬 **송수민** +> +> 안녕하세요! 저는 수영장에 가거나 차 마시는 것을 좋아하는 백엔드 개발자 송수민입니다. -### 개발 일지 -- [공통 개발 일지](https://alpine-tiglon-9f0.notion.site/12d696f85d1f80c89569dcfe55b62b44?v=12f696f85d1f802db6af000cf32dfa28&pvs=4) -- [문제 해결 일지](https://alpine-tiglon-9f0.notion.site/87b7f1ce19564eda8127eca29d567d0f?v=f2df7d634605464d876ccf43c9197db4&pvs=4) \ No newline at end of file +> 🦄 **이승윤** +> +> 안녕하십니까마귀. 저는 즐겁게 개발하는 걸 좋아합니다! 🎉 + +
diff --git a/backend/package.json b/backend/package.json index 22d92a9c..97671d25 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,7 +39,6 @@ "dotenv": "^16.4.5", "ioredis": "^5.4.1", "mysql2": "^3.11.4", - "nestjs-paginate": "^10.0.0", "nestjs-redis-om": "^0.1.2", "passport": "^0.7.0", "passport-custom": "^1.1.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 93dd2b4b..d4ced94a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { createDataSource, typeOrmConfig } from "./config/typeorm.config"; import { QuestionListModule } from "./question-list/question-list.module"; import { RedisOmModule } from "@moozeh/nestjs-redis-om"; import { SigServerModule } from "@/signaling-server/sig-server.module"; +import { QuestionModule } from './question/question.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { SigServerModule } from "@/signaling-server/sig-server.module"; UserModule, QuestionListModule, SigServerModule, + QuestionModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts index 7f5d648a..cf58c605 100644 --- a/backend/src/config/typeorm.config.ts +++ b/backend/src/config/typeorm.config.ts @@ -3,9 +3,9 @@ import { SnakeNamingStrategy } from "typeorm-naming-strategies"; import { User } from "@/user/user.entity"; import "dotenv/config"; import { addTransactionalDataSource } from "typeorm-transactional"; -import { QuestionList } from "@/question-list/question-list.entity"; -import { Question } from "@/question-list/question.entity"; -import { Category } from "@/question-list/category.entity"; +import { QuestionList } from "@/question-list/entity/question-list.entity"; +import { Question } from "@/question-list/entity/question.entity"; +import { Category } from "@/question-list/entity/category.entity"; export const typeOrmConfig: DataSourceOptions = { type: "mysql", diff --git a/backend/src/question-list/dto/paginate-meta.dto.ts b/backend/src/question-list/dto/paginate-meta.dto.ts new file mode 100644 index 00000000..79374594 --- /dev/null +++ b/backend/src/question-list/dto/paginate-meta.dto.ts @@ -0,0 +1,24 @@ +import { IsInt, IsNumberString, IsString } from "class-validator"; +import { Expose } from "class-transformer"; + +export class PaginateMetaDto { + @Expose() + @IsInt() + itemsPerPage: number; + + @Expose() + @IsInt() + totalItems: number; + + @Expose() + @IsNumberString() + currentPage: string; + + @Expose() + @IsInt() + totalPages: number; + + @Expose() + @IsString() + sortBy: string; +} diff --git a/backend/src/question-list/dto/paginate-query.dto.ts b/backend/src/question-list/dto/paginate-query.dto.ts new file mode 100644 index 00000000..2737233f --- /dev/null +++ b/backend/src/question-list/dto/paginate-query.dto.ts @@ -0,0 +1,29 @@ +import { IsInt, IsOptional, IsString, Min } from "class-validator"; +import { Expose, Transform } from "class-transformer"; + +export class PaginateQueryDto { + @Expose() + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => (value ? parseInt(value) : 1)) + page?: number; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => (value ? parseInt(value) : 4)) + limit?: number; + + @Expose() + @IsOptional() + @IsString() + @Transform(({ value }) => value || "usage:DESC") + sortBy?: string; + + @Expose() + @IsOptional() + @IsString() + category?: string; +} diff --git a/backend/src/question-list/dto/paginate.dto.ts b/backend/src/question-list/dto/paginate.dto.ts new file mode 100644 index 00000000..eb2a0b61 --- /dev/null +++ b/backend/src/question-list/dto/paginate.dto.ts @@ -0,0 +1,9 @@ +import { SelectQueryBuilder } from "typeorm"; + +export class PaginateDto { + queryBuilder: SelectQueryBuilder; + skip: number; + take: number; + field: string; + direction: "ASC" | "DESC"; +} \ No newline at end of file diff --git a/backend/src/question-list/dto/question-list-contents.dto.ts b/backend/src/question-list/dto/question-list-contents.dto.ts index 58494169..3be38c9c 100644 --- a/backend/src/question-list/dto/question-list-contents.dto.ts +++ b/backend/src/question-list/dto/question-list-contents.dto.ts @@ -1,4 +1,4 @@ -import { Question } from "../question.entity"; +import { Question } from "../entity/question.entity"; export interface QuestionListContentsDto { id: number; diff --git a/backend/src/question-list/category.entity.ts b/backend/src/question-list/entity/category.entity.ts similarity index 100% rename from backend/src/question-list/category.entity.ts rename to backend/src/question-list/entity/category.entity.ts diff --git a/backend/src/question-list/question-list.entity.ts b/backend/src/question-list/entity/question-list.entity.ts similarity index 100% rename from backend/src/question-list/question-list.entity.ts rename to backend/src/question-list/entity/question-list.entity.ts diff --git a/backend/src/question-list/question.entity.ts b/backend/src/question-list/entity/question.entity.ts similarity index 100% rename from backend/src/question-list/question.entity.ts rename to backend/src/question-list/entity/question.entity.ts diff --git a/backend/src/question-list/question-list.controller.ts b/backend/src/question-list/question-list.controller.ts index f39bbd86..daf7da15 100644 --- a/backend/src/question-list/question-list.controller.ts +++ b/backend/src/question-list/question-list.controller.ts @@ -9,6 +9,8 @@ import { Query, Req, UseGuards, + UsePipes, + ValidationPipe, } from "@nestjs/common"; import { QuestionListService } from "./question-list.service"; import { CreateQuestionListDto } from "./dto/create-question-list.dto"; @@ -19,14 +21,15 @@ import { IJwtPayload } from "@/auth/jwt/jwt.model"; import { UpdateQuestionListDto } from "@/question-list/dto/update-question-list.dto"; import { QuestionDto } from "@/question-list/dto/question.dto"; import { DeleteQuestionDto } from "@/question-list/dto/delete-question.dto"; -import { PaginateQuery } from "nestjs-paginate"; +import { PaginateQueryDto } from "@/question-list/dto/paginate-query.dto"; @Controller("question-list") export class QuestionListController { constructor(private readonly questionListService: QuestionListService) {} @Get() - async getAllQuestionLists(@Query() query: PaginateQuery) { + @UsePipes(new ValidationPipe({ transform: true })) + async getAllQuestionLists(@Query() query: PaginateQueryDto) { try { const { allQuestionLists, meta } = await this.questionListService.getAllQuestionLists(query); @@ -47,6 +50,37 @@ export class QuestionListController { } } + @Post("category") + @UsePipes(new ValidationPipe({ transform: true })) + async getAllQuestionListsByCategoryName( + @Query() query: PaginateQueryDto, + @Body() + body: { + categoryName: string; + } + ) { + try { + const { categoryName } = body; + query.category = categoryName; + const { allQuestionLists, meta } = + await this.questionListService.getAllQuestionLists(query); + return { + success: true, + message: "All question lists received successfully.", + data: { + allQuestionLists, + meta, + }, + }; + } catch (error) { + return { + success: false, + message: "Failed to get all question lists.", + error: error.message, + }; + } + } + @Post() @UseGuards(AuthGuard("jwt")) async createQuestionList( @@ -92,49 +126,20 @@ export class QuestionListController { } } - @Post("category") - async getAllQuestionListsByCategoryName( - @Query() query: PaginateQuery, - @Body() - body: { - categoryName: string; - } - ) { - try { - const { categoryName } = body; - const { allQuestionLists, meta } = - await this.questionListService.getAllQuestionListsByCategoryName( - categoryName, - query - ); - return { - success: true, - message: "All question lists received successfully.", - data: { - allQuestionLists, - meta, - }, - }; - } catch (error) { - return { - success: false, - message: "Failed to get all question lists.", - error: error.message, - }; - } - } - @Post("contents") + @UseGuards(AuthGuard("jwt")) async getQuestionListContents( + @JwtPayload() token: IJwtPayload, @Body() body: { questionListId: number; } ) { try { + const userId = token.userId; const { questionListId } = body; const questionListContents: QuestionListContentsDto = - await this.questionListService.getQuestionListContents(questionListId); + await this.questionListService.getQuestionListContents(questionListId, userId); return { success: true, message: "Question list contents received successfully.", @@ -153,7 +158,8 @@ export class QuestionListController { @Get("my") @UseGuards(AuthGuard("jwt")) - async getMyQuestionLists(@Query() query: PaginateQuery, @JwtPayload() token: IJwtPayload) { + @UsePipes(new ValidationPipe({ transform: true })) + async getMyQuestionLists(@Query() query: PaginateQueryDto, @JwtPayload() token: IJwtPayload) { try { const userId = token.userId; const { myQuestionLists, meta } = await this.questionListService.getMyQuestionLists( @@ -353,8 +359,9 @@ export class QuestionListController { @Get("scrap") @UseGuards(AuthGuard("jwt")) + @UsePipes(new ValidationPipe({ transform: true })) async getScrappedQuestionLists( - @Query() query: PaginateQuery, + @Query() query: PaginateQueryDto, @JwtPayload() token: IJwtPayload ) { try { diff --git a/backend/src/question-list/question-list.module.ts b/backend/src/question-list/question-list.module.ts index 3a75f338..7eef763c 100644 --- a/backend/src/question-list/question-list.module.ts +++ b/backend/src/question-list/question-list.module.ts @@ -1,12 +1,20 @@ import { Module } from "@nestjs/common"; import { QuestionListController } from "./question-list.controller"; import { QuestionListService } from "./question-list.service"; -import { QuestionListRepository } from "./question-list.repository"; +import { QuestionListRepository } from "./repository/question-list.repository"; import { UserRepository } from "@/user/user.repository"; +import { QuestionRepository } from "@/question-list/repository/question.respository"; +import { CategoryRepository } from "@/question-list/repository/category.repository"; @Module({ controllers: [QuestionListController], - providers: [QuestionListService, QuestionListRepository, UserRepository], - exports: [QuestionListRepository], + providers: [ + QuestionListService, + QuestionListRepository, + QuestionRepository, + UserRepository, + CategoryRepository, + ], + exports: [QuestionListRepository, QuestionRepository], }) export class QuestionListModule {} diff --git a/backend/src/question-list/question-list.repository.ts b/backend/src/question-list/question-list.repository.ts deleted file mode 100644 index 1f248483..00000000 --- a/backend/src/question-list/question-list.repository.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { DataSource, In } from "typeorm"; -import { QuestionList } from "./question-list.entity"; -import { Question } from "./question.entity"; -import { Category } from "./category.entity"; -import { User } from "@/user/user.entity"; -import { UpdateQuestionListDto } from "@/question-list/dto/update-question-list.dto"; - -@Injectable() -export class QuestionListRepository { - constructor(private dataSource: DataSource) {} - - createQuestionList(questionList: QuestionList) { - return this.dataSource.getRepository(QuestionList).save(questionList); - } - - async createQuestions(questions: Question[]) { - return this.dataSource.getRepository(Question).save(questions); - } - - findPublicQuestionLists() { - return this.dataSource - .getRepository(QuestionList) - .createQueryBuilder("question_list") - .where("question_list.is_public = :isPublic", { isPublic: true }); - } - - async getCategoryIdByName(categoryName: string) { - const category = await this.dataSource.getRepository(Category).findOne({ - where: { name: categoryName }, - select: ["id"], - }); - - return category?.id || null; - } - - findPublicQuestionListsByCategoryId(categoryId: number) { - return this.dataSource - .getRepository(QuestionList) - .createQueryBuilder("question_list") - .innerJoin("question_list.categories", "category") - .where("question_list.is_public = :isPublic", { isPublic: true }) - .andWhere("category.id = :categoryId", { categoryId }); - } - - async findCategoryNamesByQuestionListId(questionListId: number) { - const questionList = await this.dataSource.getRepository(QuestionList).findOne({ - where: { id: questionListId }, - relations: ["categories"], // 질문지와 관련된 카테고리도 함께 조회 - }); - - return questionList ? questionList.categories.map((category) => category.name) : []; - } - - async findCategoriesByNames(categoryNames: string[]) { - return this.dataSource.getRepository(Category).find({ - where: { - name: In(categoryNames), - }, - }); - } - - getQuestionListById(questionListId: number) { - return this.dataSource.getRepository(QuestionList).findOne({ - where: { id: questionListId }, - }); - } - - getContentsByQuestionListId(questionListId: number) { - return this.dataSource - .getRepository(Question) - .createQueryBuilder("question") - .where("question.question_list_id = :questionListId", { questionListId }) - .getMany(); - } - - async getUsernameById(userId: number) { - const user = await this.dataSource.getRepository(User).findOne({ - where: { id: userId }, - }); - - return user?.username || null; - } - - getQuestionListsByUserId(userId: number) { - return this.dataSource - .getRepository(QuestionList) - .createQueryBuilder("question_list") - .where("question_list.userId = :userId", { userId }); - } - - getQuestionCountByQuestionListId(questionListId: number) { - return this.dataSource - .getRepository(Question) - .createQueryBuilder("question") - .where("question.questionListId = :questionListId", { - questionListId, - }) - .getCount(); - } - - updateQuestionList(updateQuestionListDto: UpdateQuestionListDto) { - return this.dataSource.getRepository(QuestionList).save(updateQuestionListDto); - } - - deleteQuestionList(questionListId: number) { - return this.dataSource.getRepository(QuestionList).delete(questionListId); - } - - saveQuestion(question: Question) { - return this.dataSource.getRepository(Question).save(question); - } - - getQuestionById(questionId: number) { - return this.dataSource.getRepository(Question).findOne({ - where: { id: questionId }, - }); - } - - getQuestionsAfterIndex(questionListId: number, index: number) { - return this.dataSource - .getRepository(Question) - .createQueryBuilder("question") - .where("question.questionListId = :questionListId", { questionListId }) - .andWhere("question.index > :index", { index }) - .orderBy("question.index", "ASC") - .getMany(); - } - - deleteQuestion(question: Question) { - return this.dataSource.getRepository(Question).delete(question); - } - - scrapQuestionList(questionListId: number, userId: number) { - return this.dataSource - .createQueryBuilder() - .insert() - .into("user_question_list") - .values({ - user_id: userId, - question_list_id: questionListId, - }) - .orIgnore() - .execute(); - } - - getScrappedQuestionListsByUser(user: User) { - return this.dataSource - .getRepository(QuestionList) - .createQueryBuilder("question_list") - .innerJoin("question_list.scrappedByUsers", "user") - .where("user.id = :userId", { userId: user.id }); - } - - unscrapQuestionList(questionListId: number, userId: number) { - return this.dataSource - .createQueryBuilder() - .delete() - .from("user_question_list") - .where("user_id = :userId", { userId }) - .andWhere("question_list_id = :questionListId", { questionListId }) - .execute(); - } -} diff --git a/backend/src/question-list/question-list.service.ts b/backend/src/question-list/question-list.service.ts index 0cb3762e..eb393015 100644 --- a/backend/src/question-list/question-list.service.ts +++ b/backend/src/question-list/question-list.service.ts @@ -1,77 +1,52 @@ import { Injectable } from "@nestjs/common"; -import { QuestionListRepository } from "./question-list.repository"; +import { QuestionListRepository } from "./repository/question-list.repository"; import { UserRepository } from "@/user/user.repository"; import { CreateQuestionListDto } from "./dto/create-question-list.dto"; import { GetAllQuestionListDto } from "./dto/get-all-question-list.dto"; import { QuestionListContentsDto } from "./dto/question-list-contents.dto"; import { MyQuestionListDto } from "./dto/my-question-list.dto"; -import { Question } from "./question.entity"; +import { Question } from "./entity/question.entity"; import { Transactional } from "typeorm-transactional"; -import { QuestionList } from "@/question-list/question-list.entity"; +import { QuestionList } from "@/question-list/entity/question-list.entity"; import { UpdateQuestionListDto } from "@/question-list/dto/update-question-list.dto"; import { QuestionDto } from "@/question-list/dto/question.dto"; import { DeleteQuestionDto } from "@/question-list/dto/delete-question.dto"; -import { paginate, PaginateQuery } from "nestjs-paginate"; +import { QuestionRepository } from "@/question-list/repository/question.respository"; +import { CategoryRepository } from "@/question-list/repository/category.repository"; +import { PaginateQueryDto } from "@/question-list/dto/paginate-query.dto"; +import { SelectQueryBuilder } from "typeorm"; +import { PaginateMetaDto } from "@/question-list/dto/paginate-meta.dto"; +import { PaginateDto } from "@/question-list/dto/paginate.dto"; @Injectable() export class QuestionListService { constructor( private readonly questionListRepository: QuestionListRepository, - private readonly userRepository: UserRepository + private readonly questionRepository: QuestionRepository, + private readonly userRepository: UserRepository, + private readonly categoryRepository: CategoryRepository ) {} - async getAllQuestionLists(query: PaginateQuery) { + async getAllQuestionLists(query: PaginateQueryDto) { const allQuestionLists: GetAllQuestionListDto[] = []; - const publicQuestionLists = await this.questionListRepository.findPublicQuestionLists(); - const result = await paginate(query, publicQuestionLists, { - sortableColumns: ["usage"], - defaultSortBy: [["usage", "DESC"]], - }); - - for (const publicQuestionList of result.data) { - const { id, title, usage } = publicQuestionList; - const categoryNames: string[] = - await this.questionListRepository.findCategoryNamesByQuestionListId(id); - - const questionCount = - await this.questionListRepository.getQuestionCountByQuestionListId(id); - - const questionList: GetAllQuestionListDto = { - id, - title, - categoryNames, - usage, - questionCount, - }; - allQuestionLists.push(questionList); - } - return { allQuestionLists, meta: result.meta }; - } - - async getAllQuestionListsByCategoryName(categoryName: string, query: PaginateQuery) { - const allQuestionLists: GetAllQuestionListDto[] = []; - - const categoryId = await this.questionListRepository.getCategoryIdByName(categoryName); - - if (!categoryId) { - return {}; + let categoryId = null; + if (query.category) { + categoryId = await this.categoryRepository.getCategoryIdByName(query.category); + if (!categoryId) return {}; } const publicQuestionLists = - await this.questionListRepository.findPublicQuestionListsByCategoryId(categoryId); - const result = await paginate(query, publicQuestionLists, { - sortableColumns: ["usage"], - defaultSortBy: [["usage", "DESC"]], - }); + await this.questionListRepository.findPublicQuestionLists(categoryId); + const result = await this.paginate(query, publicQuestionLists); for (const publicQuestionList of result.data) { const { id, title, usage } = publicQuestionList; const categoryNames: string[] = - await this.questionListRepository.findCategoryNamesByQuestionListId(id); + await this.categoryRepository.findCategoryNamesByQuestionListId(id); const questionCount = - await this.questionListRepository.getQuestionCountByQuestionListId(id); + await this.questionRepository.getQuestionCountByQuestionListId(id); const questionList: GetAllQuestionListDto = { id, @@ -90,7 +65,7 @@ export class QuestionListService { async createQuestionList(createQuestionListDto: CreateQuestionListDto) { const { title, contents, categoryNames, isPublic, userId } = createQuestionListDto; - const categories = await this.questionListRepository.findCategoriesByNames(categoryNames); + const categories = await this.categoryRepository.findCategoriesByNames(categoryNames); if (categories.length !== categoryNames.length) { throw new Error("Some category names were not found."); } @@ -113,24 +88,24 @@ export class QuestionListService { return question; }); - const createdQuestions = await this.questionListRepository.createQuestions(questions); + const createdQuestions = await this.questionRepository.createQuestions(questions); return { createdQuestionList, createdQuestions }; } - async getQuestionListContents(questionListId: number) { + async getQuestionListContents(questionListId: number, userId: number) { const questionList = await this.questionListRepository.getQuestionListById(questionListId); - const { id, title, usage, isPublic, userId } = questionList; - if (!isPublic) { + const { id, title, usage, isPublic } = questionList; + if (!isPublic && questionList.userId !== userId) { throw new Error("This is private question list."); } - const contents = - await this.questionListRepository.getContentsByQuestionListId(questionListId); + const contents = await this.questionRepository.getContentsByQuestionListId(questionListId); const categoryNames = - await this.questionListRepository.findCategoryNamesByQuestionListId(questionListId); + await this.categoryRepository.findCategoryNamesByQuestionListId(questionListId); - const username = await this.questionListRepository.getUsernameById(userId); + const user = await this.userRepository.getUserByUserId(userId); + const username = user.username; const questionListContents: QuestionListContentsDto = { id, title, @@ -143,18 +118,15 @@ export class QuestionListService { return questionListContents; } - async getMyQuestionLists(userId: number, query: PaginateQuery) { + async getMyQuestionLists(userId: number, query: PaginateQueryDto) { const questionLists = await this.questionListRepository.getQuestionListsByUserId(userId); - const result = await paginate(query, questionLists, { - sortableColumns: ["usage"], - defaultSortBy: [["usage", "DESC"]], - }); + const result = await this.paginate(query, questionLists); const myQuestionLists: MyQuestionListDto[] = []; for (const myQuestionList of result.data) { const { id, title, isPublic, usage } = myQuestionList; const categoryNames: string[] = - await this.questionListRepository.findCategoryNamesByQuestionListId(id); + await this.categoryRepository.findCategoryNamesByQuestionListId(id); const questionList: MyQuestionListDto = { id, @@ -169,7 +141,7 @@ export class QuestionListService { } async findCategoriesByNames(categoryNames: string[]) { - const categories = await this.questionListRepository.findCategoriesByNames(categoryNames); + const categories = await this.categoryRepository.findCategoriesByNames(categoryNames); if (categories.length !== categoryNames.length) { throw new Error("Some category names were not found."); @@ -191,14 +163,14 @@ export class QuestionListService { if (title) questionList.title = title; if (categoryNames) { questionList.categories = - await this.questionListRepository.findCategoriesByNames(categoryNames); + await this.categoryRepository.findCategoriesByNames(categoryNames); } if (isPublic !== undefined) questionList.isPublic = isPublic; const updatedQuestionList = await this.questionListRepository.updateQuestionList(questionList); updatedQuestionList.categoryNames = - await this.questionListRepository.findCategoryNamesByQuestionListId(id); + await this.categoryRepository.findCategoryNamesByQuestionListId(id); updatedQuestionList.categories = undefined; return updatedQuestionList; @@ -227,20 +199,20 @@ export class QuestionListService { throw new Error("You do not have permission to add a question to this question list."); const existingQuestionsCount = - await this.questionListRepository.getQuestionCountByQuestionListId(questionListId); + await this.questionRepository.getQuestionCountByQuestionListId(questionListId); const question = new Question(); question.content = content; question.index = existingQuestionsCount; question.questionListId = questionListId; - await this.questionListRepository.saveQuestion(question); - return await this.getQuestionListContents(questionListId); + await this.questionRepository.saveQuestion(question); + return await this.getQuestionListContents(questionListId, userId); } async updateQuestion(questionDto: QuestionDto) { const { id, content, questionListId, userId } = questionDto; - const question = await this.questionListRepository.getQuestionById(id); + const question = await this.questionRepository.getQuestionById(id); if (!question) throw new Error("Question not found."); const user = await this.userRepository.getUserByUserId(userId); @@ -253,18 +225,18 @@ export class QuestionListService { "You do not have permission to update the question in this question list." ); - const existingQuestion = await this.questionListRepository.getQuestionById(id); + const existingQuestion = await this.questionRepository.getQuestionById(id); existingQuestion.content = content; - await this.questionListRepository.saveQuestion(existingQuestion); - return await this.getQuestionListContents(questionListId); + await this.questionRepository.saveQuestion(existingQuestion); + return await this.getQuestionListContents(questionListId, userId); } @Transactional() async deleteQuestion(deleteQuestionDto: DeleteQuestionDto) { const { id, questionListId, userId } = deleteQuestionDto; - const question = await this.questionListRepository.getQuestionById(id); + const question = await this.questionRepository.getQuestionById(id); if (!question) throw new Error("Question not found."); const user = await this.userRepository.getUserByUserId(userId); @@ -279,27 +251,24 @@ export class QuestionListService { const questionIndex = question.index; - const questionsToUpdate = await this.questionListRepository.getQuestionsAfterIndex( + const questionsToUpdate = await this.questionRepository.getQuestionsAfterIndex( questionListId, questionIndex ); for (const q of questionsToUpdate) { q.index -= 1; - await this.questionListRepository.saveQuestion(q); + await this.questionRepository.saveQuestion(q); } - return await this.questionListRepository.deleteQuestion(question); + return await this.questionRepository.deleteQuestion(question); } - async getScrappedQuestionLists(userId: number, query: PaginateQuery) { + async getScrappedQuestionLists(userId: number, query: PaginateQueryDto) { const user = await this.userRepository.getUserByUserId(userId); const scrappedQuestionLists = await this.questionListRepository.getScrappedQuestionListsByUser(user); - const result = await paginate(query, scrappedQuestionLists, { - sortableColumns: ["usage"], - defaultSortBy: [["usage", "DESC"]], - }); + const result = await this.paginate(query, scrappedQuestionLists); return { scrappedQuestionLists: result.data, meta: result.meta }; } @@ -335,4 +304,40 @@ export class QuestionListService { async unscrapQuestionList(questionListId: number, userId: number) { return await this.questionListRepository.unscrapQuestionList(questionListId, userId); } + + async paginate(query: PaginateQueryDto, queryBuilder: SelectQueryBuilder) { + const { page, limit, sortBy } = query; + + const skip = (page - 1) * limit; + const take = limit; + const [field, direction] = sortBy.split(":"); + const safeDirection: "ASC" | "DESC" = + direction.toUpperCase() === "ASC" || direction.toUpperCase() === "DESC" + ? (direction.toUpperCase() as "ASC" | "DESC") + : "DESC"; + + const paginateDto: PaginateDto = { + queryBuilder, + skip, + take, + field, + direction: safeDirection, + }; + + const [result, totalItems] = await this.questionListRepository.paginate(paginateDto); + const totalPages = Math.ceil(totalItems / limit); + + const meta: PaginateMetaDto = { + itemsPerPage: limit, + totalItems, + currentPage: String(page), + totalPages, + sortBy: sortBy, + }; + + return { + data: result, + meta: meta, + }; + } } diff --git a/backend/src/question-list/repository/category.repository.ts b/backend/src/question-list/repository/category.repository.ts new file mode 100644 index 00000000..a13824b3 --- /dev/null +++ b/backend/src/question-list/repository/category.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, In } from "typeorm"; +import { Category } from "@/question-list/entity/category.entity"; + +@Injectable() +export class CategoryRepository { + constructor(private dataSource: DataSource) {} + + async getCategoryIdByName(categoryName: string) { + const category = await this.dataSource.getRepository(Category).findOne({ + where: { name: categoryName }, + select: ["id"], + }); + + return category?.id || null; + } + + findCategoriesByNames(categoryNames: string[]) { + return this.dataSource.getRepository(Category).find({ + where: { + name: In(categoryNames), + }, + }); + } + + async findCategoryNamesByQuestionListId(questionListId: number) { + const categories = await this.dataSource + .getRepository(Category) + .createQueryBuilder("category") + .innerJoin("category.questionLists", "questionList") + .where("questionList.id = :questionListId", { questionListId }) + .select("category.name") + .getMany(); + + return categories.map((category) => category.name); + } +} diff --git a/backend/src/question-list/repository/question-list.repository.ts b/backend/src/question-list/repository/question-list.repository.ts new file mode 100644 index 00000000..df397d7a --- /dev/null +++ b/backend/src/question-list/repository/question-list.repository.ts @@ -0,0 +1,90 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, SelectQueryBuilder } from "typeorm"; +import { QuestionList } from "../entity/question-list.entity"; +import { User } from "@/user/user.entity"; +import { UpdateQuestionListDto } from "@/question-list/dto/update-question-list.dto"; +import { PaginateDto } from "@/question-list/dto/paginate.dto"; + +@Injectable() +export class QuestionListRepository { + constructor(private dataSource: DataSource) {} + + createQuestionList(questionList: QuestionList) { + return this.dataSource.getRepository(QuestionList).save(questionList); + } + + findPublicQuestionLists(categoryId?: number) { + const query = this.dataSource + .getRepository(QuestionList) + .createQueryBuilder("question_list") + .where("question_list.is_public = :isPublic", { isPublic: true }); + + if (categoryId !== null) { + query + .innerJoin("question_list.categories", "category") + .andWhere("category.id = :categoryId", { categoryId }); + } + + return query; + } + + getQuestionListById(questionListId: number) { + return this.dataSource.getRepository(QuestionList).findOne({ + where: { id: questionListId }, + }); + } + + getQuestionListsByUserId(userId: number) { + return this.dataSource + .getRepository(QuestionList) + .createQueryBuilder("question_list") + .where("question_list.userId = :userId", { userId }); + } + + updateQuestionList(updateQuestionListDto: UpdateQuestionListDto) { + return this.dataSource.getRepository(QuestionList).save(updateQuestionListDto); + } + + deleteQuestionList(questionListId: number) { + return this.dataSource.getRepository(QuestionList).delete(questionListId); + } + scrapQuestionList(questionListId: number, userId: number) { + return this.dataSource + .createQueryBuilder() + .insert() + .into("user_question_list") + .values({ + user_id: userId, + question_list_id: questionListId, + }) + .orIgnore() + .execute(); + } + + getScrappedQuestionListsByUser(user: User) { + return this.dataSource + .getRepository(QuestionList) + .createQueryBuilder("question_list") + .innerJoin("question_list.scrappedByUsers", "user") + .where("user.id = :userId", { userId: user.id }); + } + + unscrapQuestionList(questionListId: number, userId: number) { + return this.dataSource + .createQueryBuilder() + .delete() + .from("user_question_list") + .where("user_id = :userId", { userId }) + .andWhere("question_list_id = :questionListId", { questionListId }) + .execute(); + } + + async paginate(paginateDto: PaginateDto) { + const { queryBuilder, skip, take, field, direction } = paginateDto; + return await queryBuilder + .addOrderBy(`question_list.${field}`, direction) + .skip(skip) + .take(take) + .getManyAndCount(); + } +} diff --git a/backend/src/question-list/repository/question.respository.ts b/backend/src/question-list/repository/question.respository.ts new file mode 100644 index 00000000..e7629c5e --- /dev/null +++ b/backend/src/question-list/repository/question.respository.ts @@ -0,0 +1,54 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { Question } from "@/question-list/entity/question.entity"; + +@Injectable() +export class QuestionRepository { + constructor(private dataSource: DataSource) {} + + getQuestionById(questionId: number) { + return this.dataSource.getRepository(Question).findOne({ + where: { id: questionId }, + }); + } + + getContentsByQuestionListId(questionListId: number) { + return this.dataSource + .getRepository(Question) + .createQueryBuilder("question") + .where("question.question_list_id = :questionListId", { questionListId }) + .getMany(); + } + + getQuestionCountByQuestionListId(questionListId: number) { + return this.dataSource + .getRepository(Question) + .createQueryBuilder("question") + .where("question.questionListId = :questionListId", { + questionListId, + }) + .getCount(); + } + + getQuestionsAfterIndex(questionListId: number, index: number) { + return this.dataSource + .getRepository(Question) + .createQueryBuilder("question") + .where("question.questionListId = :questionListId", { questionListId }) + .andWhere("question.index > :index", { index }) + .orderBy("question.index", "ASC") + .getMany(); + } + + createQuestions(questions: Question[]) { + return this.dataSource.getRepository(Question).save(questions); + } + + saveQuestion(question: Question) { + return this.dataSource.getRepository(Question).save(question); + } + + deleteQuestion(question: Question) { + return this.dataSource.getRepository(Question).delete(question); + } +} diff --git a/backend/src/question/question.module.ts b/backend/src/question/question.module.ts new file mode 100644 index 00000000..f0fb1b13 --- /dev/null +++ b/backend/src/question/question.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { QuestionRepository } from "@/question-list/repository/question.respository"; + +@Module({ + providers: [QuestionRepository], + exports: [QuestionRepository], +}) +export class QuestionModule {} diff --git a/backend/src/room/dto/room-list.dto.ts b/backend/src/room/dto/room-list.dto.ts new file mode 100644 index 00000000..adee64a7 --- /dev/null +++ b/backend/src/room/dto/room-list.dto.ts @@ -0,0 +1,30 @@ +import { Connection, RoomStatus } from "@/room/room.entity"; +import { Max, Min } from "class-validator"; + +export class RoomList { + createdAt: number; + + host: Connection; + + @Max(5) + @Min(1) + maxParticipants: number; + + status: RoomStatus; + + title: string; + + id: string; + + category: string[]; + + inProgress: boolean; + + questionListTitle: string; + + @Max(5) + @Min(1) + participants: number; +} + +export type RoomListResponseDto = RoomList[]; diff --git a/backend/src/room/dto/room.dto.ts b/backend/src/room/dto/room.dto.ts index a5718d73..c6949fe2 100644 --- a/backend/src/room/dto/room.dto.ts +++ b/backend/src/room/dto/room.dto.ts @@ -12,6 +12,7 @@ export interface RoomDto { maxParticipants: number; maxQuestionListLength: number; questionListId: number; + questionListTitle: string; createdAt: number; connectionMap: Record; } diff --git a/backend/src/room/room.entity.ts b/backend/src/room/room.entity.ts index 29b84aac..569f0738 100644 --- a/backend/src/room/room.entity.ts +++ b/backend/src/room/room.entity.ts @@ -37,6 +37,9 @@ export class RoomEntity extends Entity { @Field({ type: "number" }) questionListId: number; + @Field({ type: "string" }) + questionListTitle: string; + @Field({ type: "number" }) maxQuestionListLength: number; diff --git a/backend/src/room/room.module.ts b/backend/src/room/room.module.ts index b02c430b..7de1507d 100644 --- a/backend/src/room/room.module.ts +++ b/backend/src/room/room.module.ts @@ -7,10 +7,11 @@ import { RedisOmModule } from "@moozeh/nestjs-redis-om"; import { RoomEntity } from "./room.entity"; import { RoomLeaveService } from "@/room/services/room-leave.service"; import { RoomHostService } from "@/room/services/room-host.service"; -import { QuestionListRepository } from "@/question-list/question-list.repository"; +import { QuestionListRepository } from "@/question-list/repository/question-list.repository"; import { WebsocketModule } from "@/websocket/websocket.module"; import { RoomCreateService } from "@/room/services/room-create.service"; import { RoomJoinService } from "@/room/services/room-join.service"; +import { QuestionRepository } from "@/question-list/repository/question.respository"; @Module({ imports: [RedisOmModule.forFeature([RoomEntity]), WebsocketModule], @@ -23,6 +24,7 @@ import { RoomJoinService } from "@/room/services/room-join.service"; RoomLeaveService, RoomHostService, QuestionListRepository, + QuestionRepository, ], controllers: [RoomController], }) diff --git a/backend/src/room/room.repository.ts b/backend/src/room/room.repository.ts index c4273d30..7e1fd6c3 100644 --- a/backend/src/room/room.repository.ts +++ b/backend/src/room/room.repository.ts @@ -23,6 +23,7 @@ export class RoomRepository { maxParticipants: room.maxParticipants, maxQuestionListLength: room.maxQuestionListLength, questionListId: room.questionListId, + questionListTitle: room.questionListTitle, currentIndex: room.currentIndex, status: room.status, title: room.title, @@ -49,6 +50,7 @@ export class RoomRepository { currentIndex: room.currentIndex, maxQuestionListLength: room.maxQuestionListLength, questionListId: room.questionListId, + questionListTitle: room.questionListTitle, host: JSON.parse(room.host), participants: Object.keys(connectionMap).length, maxParticipants: room.maxParticipants, @@ -70,6 +72,7 @@ export class RoomRepository { room.maxParticipants = dto.maxParticipants; room.maxQuestionListLength = dto.maxQuestionListLength; room.questionListId = dto.questionListId; + room.questionListTitle = dto.questionListTitle; room.createdAt = Date.now(); room.host = JSON.stringify(dto.host); diff --git a/backend/src/room/services/room-create.service.ts b/backend/src/room/services/room-create.service.ts index b91b626c..ea257f9f 100644 --- a/backend/src/room/services/room-create.service.ts +++ b/backend/src/room/services/room-create.service.ts @@ -3,10 +3,11 @@ import { CreateRoomInternalDto } from "@/room/dto/create-room.dto"; import { EMIT_EVENT } from "@/room/room.events"; import { WebsocketService } from "@/websocket/websocket.service"; import { RoomRepository } from "@/room/room.repository"; -import { QuestionListRepository } from "@/question-list/question-list.repository"; -import { RoomJoinService } from "@/room/services/room-join.service"; +import { QuestionListRepository } from "@/question-list/repository/question-list.repository"; import { createHash } from "node:crypto"; import "dotenv/config"; +import { Transactional } from "typeorm-transactional"; +import { QuestionRepository } from "@/question-list/repository/question.respository"; @Injectable() export class RoomCreateService { @@ -16,17 +17,23 @@ export class RoomCreateService { private readonly roomRepository: RoomRepository, private readonly socketService: WebsocketService, private readonly questionListRepository: QuestionListRepository, - private readonly roomJoinService: RoomJoinService + private readonly questionRepository: QuestionRepository ) {} + @Transactional() public async createRoom(dto: CreateRoomInternalDto) { const { socketId, nickname } = dto; const id = await this.generateRoomId(); const socket = this.socketService.getSocket(socketId); const currentTime = Date.now(); - const questionListContents = await this.questionListRepository.getContentsByQuestionListId( + const questionList = await this.questionListRepository.getQuestionListById( dto.questionListId ); + const questionListContent = await this.questionRepository.getContentsByQuestionListId( + dto.questionListId + ); + questionList.usage += 1; + await this.questionListRepository.updateQuestionList(questionList); const roomDto = { ...dto, @@ -34,9 +41,10 @@ export class RoomCreateService { inProgress: false, connectionMap: {}, participants: 0, - questionListContents, + questionListContents: questionListContent, createdAt: currentTime, - maxQuestionListLength: questionListContents.length, + maxQuestionListLength: questionListContent.length, + questionListTitle: questionList.title, currentIndex: 0, host: { socketId: dto.socketId, diff --git a/backend/src/room/services/room-join.service.ts b/backend/src/room/services/room-join.service.ts index 51321894..397817dc 100644 --- a/backend/src/room/services/room-join.service.ts +++ b/backend/src/room/services/room-join.service.ts @@ -5,7 +5,8 @@ import { RoomRepository } from "@/room/room.repository"; import { RoomDto } from "@/room/dto/room.dto"; import { JoinRoomInternalDto } from "@/room/dto/join-room.dto"; import { WebsocketRepository } from "@/websocket/websocket.repository"; -import { QuestionListRepository } from "@/question-list/question-list.repository"; +import { QuestionListRepository } from "@/question-list/repository/question-list.repository"; +import { QuestionRepository } from "@/question-list/repository/question.respository"; @Injectable() export class RoomJoinService { @@ -13,7 +14,8 @@ export class RoomJoinService { private readonly roomRepository: RoomRepository, private readonly socketService: WebsocketService, private readonly socketRepository: WebsocketRepository, - private readonly questionListRepository: QuestionListRepository + private readonly questionListRepository: QuestionListRepository, + private readonly questionRepository: QuestionRepository ) {} public async joinRoom(dto: JoinRoomInternalDto) { @@ -44,7 +46,7 @@ export class RoomJoinService { room.connectionMap[socketId] = undefined; - const questionListContents = await this.questionListRepository.getContentsByQuestionListId( + const questionListContents = await this.questionRepository.getContentsByQuestionListId( room.questionListId ); diff --git a/backend/src/room/services/room.service.ts b/backend/src/room/services/room.service.ts index 3f0fe6fd..782d7380 100644 --- a/backend/src/room/services/room.service.ts +++ b/backend/src/room/services/room.service.ts @@ -1,16 +1,29 @@ import { Injectable } from "@nestjs/common"; import { RoomStatus } from "@/room/room.entity"; import { RoomRepository } from "@/room/room.repository"; +import { RoomListResponseDto } from "@/room/dto/room-list.dto"; @Injectable() export class RoomService { public constructor(private readonly roomRepository: RoomRepository) {} - public async getPublicRoom() { + public async getPublicRoom(): Promise { const rooms = await this.roomRepository.getAllRoom(); return rooms .filter((room) => room.status === RoomStatus.PUBLIC) - .sort((a, b) => b.createdAt - a.createdAt); + .sort((a, b) => b.createdAt - a.createdAt) + .map((room) => ({ + createdAt: room.createdAt, + host: room.host, + maxParticipants: room.maxParticipants, + status: room.status, + title: room.title, + id: room.id, + category: room.category, + inProgress: room.inProgress, + questionListTitle: room.questionListTitle, + participants: room.participants, + })); } public async setProgress(roomId: string, socketId: string, status: boolean) { diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index fd814e78..d132199f 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -1,4 +1,4 @@ -import { QuestionList } from "@/question-list/question-list.entity"; +import { QuestionList } from "@/question-list/entity/question-list.entity"; import { LoginType } from "@/user/user.entity"; export interface UserDto { diff --git a/backend/src/user/user.entity.ts b/backend/src/user/user.entity.ts index 5b014fe6..924cd75d 100644 --- a/backend/src/user/user.entity.ts +++ b/backend/src/user/user.entity.ts @@ -1,5 +1,5 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm"; -import { QuestionList } from "@/question-list/question-list.entity"; +import { QuestionList } from "@/question-list/entity/question-list.entity"; export enum LoginType { LOCAL = "local", diff --git a/backend/src/user/user.repository.ts b/backend/src/user/user.repository.ts index b514cfd0..53402ea5 100644 --- a/backend/src/user/user.repository.ts +++ b/backend/src/user/user.repository.ts @@ -1,43 +1,37 @@ import { Injectable } from "@nestjs/common"; import { User } from "./user.entity"; -import { DataSource } from "typeorm"; +import { DataSource, Repository } from "typeorm"; import { CreateUserInternalDto } from "./dto/create-user.dto"; import { UserDto } from "./dto/user.dto"; @Injectable() -export class UserRepository { - constructor(private dataSource: DataSource) {} +export class UserRepository extends Repository { + constructor(private dataSource: DataSource) { + super(User, dataSource.createEntityManager()); + } getUserByGithubId(githubId: number) { - return this.dataSource - .getRepository(User) - .createQueryBuilder("user") + return this.createQueryBuilder("user") .where("user.github_id = :id", { id: githubId }) .getOne(); } getUserByUserId(userId: number) { - return this.dataSource - .getRepository(User) - .createQueryBuilder("user") - .where("user.id = :id", { id: userId }) - .getOne(); + return this.createQueryBuilder("user").where("user.id = :id", { id: userId }).getOne(); } // TODO: 로그인 ID로 인덱싱 생성 필요? getUserByLoginId(username: string) { - return this.dataSource - .getRepository(User) - .createQueryBuilder("user") + return this.createQueryBuilder("user") .where("user.login_id = :id", { id: username }) .getOne(); } createUser(createUserDto: CreateUserInternalDto) { - return this.dataSource.getRepository(User).save(createUserDto); + return this.save(createUserDto); } updateUser(userDto: UserDto) { - return this.dataSource.getRepository(User).save(userDto); + return this.save(userDto); } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 161405ca..c344642a 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -21,7 +21,7 @@ export class UserService { userId: user.id, nickname: user.username, avatarUrl: user.avatarUrl, - loginType: user.loginType, + loginType: user.loginType === "local" ? "native" : user.loginType, }; } @@ -55,11 +55,31 @@ export class UserService { passwordHash: this.authService.generatePasswordHash(dto.password), }; + const idExists = await this.userRepository.exists({ + where: { loginId: dto.id }, + }); + + if (idExists) + return { + code: `DUPLICATE_ID`, + message: `아이디가 중복되었습니다.`, + field: "id", + }; + + const nameExists = await this.userRepository.exists({ + where: { username: dto.nickname }, + }); + + if (nameExists) + return { + code: `DUPLICATE_NICKNAME`, + message: `닉네임이 중복되었습니다.`, + field: "nickname", + }; + await this.userRepository.createUser(userDto); - return { - status: "success", - }; + return { status: "success" }; } @Transactional() diff --git a/frontend/index.html b/frontend/index.html index 0558478c..68b620e8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,7 +24,7 @@ /> diff --git a/frontend/package.json b/frontend/package.json index 8861278a..6d0c0a66 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@tailwindcss/aspect-ratio": "^0.4.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^15.0.6", "@testing-library/react-hooks": "^8.0.1", diff --git a/frontend/public/assets/notfound.lottie b/frontend/public/assets/notfound.lottie new file mode 100644 index 00000000..b1dd3ee2 Binary files /dev/null and b/frontend/public/assets/notfound.lottie differ diff --git a/frontend/public/preview-banner.png b/frontend/public/preview-banner.png new file mode 100644 index 00000000..8f1a1ece Binary files /dev/null and b/frontend/public/preview-banner.png differ diff --git a/frontend/src/api/config/axios.ts b/frontend/src/api/config/axios.ts new file mode 100644 index 00000000..9609e20f --- /dev/null +++ b/frontend/src/api/config/axios.ts @@ -0,0 +1,31 @@ +import axios, { AxiosError } from "axios"; +import useAuth from "@hooks/useAuth.ts"; +import { useNavigate } from "react-router-dom"; + +export const api = axios.create({ + timeout: 5000, + withCredentials: true, +}); + +// 401에러일때의 처리 +// 현재 서비스를 사용하다가 인증 에러가 난거니까 현재 서비스 url을 기록하고 로그인 후 복귀 하는 것을 목표 +api.interceptors.response.use( + (response) => { + return response; + }, + async (error: AxiosError) => { + if (error.response?.status === 401) { + const { logOut } = useAuth(); + const navigate = useNavigate(); + const currentPath = window.location.pathname; + localStorage.setItem("redirectUrl", currentPath); + + // 로그아웃처리 + logOut(); + + // 로그인 페이지로 리다이렉트 + navigate("/login", { replace: true }); + } + return Promise.reject(error); + } +); diff --git a/frontend/src/api/question-list/getQuestionList.ts b/frontend/src/api/question-list/getQuestionList.ts index 8bdd78ec..95560747 100644 --- a/frontend/src/api/question-list/getQuestionList.ts +++ b/frontend/src/api/question-list/getQuestionList.ts @@ -1,12 +1,17 @@ -import axios from "axios"; +import { api } from "@/api/config/axios.ts"; +import type { QuestionList } from "@/pages/QuestionListPage/types/QuestionList"; interface QuestionListProps { - page: number; - limit: number; + page?: number; + limit?: number; + categoryName?: string; } -export const getQuestionList = async ({ page, limit }: QuestionListProps) => { - const response = await axios.get("/api/question-list", { +export const getQuestionList = async ({ + page, + limit, +}: QuestionListProps): Promise => { + const response = await api.get("/api/question-list", { params: { page, limit, @@ -15,3 +20,23 @@ export const getQuestionList = async ({ page, limit }: QuestionListProps) => { return response.data.data.allQuestionLists; }; + +export const getQuestionListWithCategory = async ({ + categoryName, + page, + limit, +}: QuestionListProps): Promise => { + const response = await api.post( + `/api/question-list/category`, + { + categoryName, + }, + { + params: { + page, + limit, + }, + } + ); + return response.data.data.allQuestionLists; +}; diff --git a/frontend/src/api/session-list/getSessionList.ts b/frontend/src/api/session-list/getSessionList.ts new file mode 100644 index 00000000..d416ef7c --- /dev/null +++ b/frontend/src/api/session-list/getSessionList.ts @@ -0,0 +1,7 @@ +import { api } from "@/api/config/axios.ts"; +import { Session } from "@/pages/SessionListPage/types/session"; + +export const getSessionList = async (): Promise => { + const response = await api.get("api/rooms"); + return response.data; +}; diff --git a/frontend/src/api/user/editMyInfo.ts b/frontend/src/api/user/editMyInfo.ts new file mode 100644 index 00000000..91a19024 --- /dev/null +++ b/frontend/src/api/user/editMyInfo.ts @@ -0,0 +1,24 @@ +import { api } from "@/api/config/axios"; + +interface EditMyInfoRequest { + nickname?: string; + avatarUrl?: string; + password?: { + original: string; + newPassword: string; + }; +} + +export const editMyInfo = async ({ + nickname, + avatarUrl, + password, +}: EditMyInfoRequest) => { + const response = await api.patch("/api/user/my", { + nickname, + avatarUrl, + password, + }); + + return response.data; +}; diff --git a/frontend/src/api/user/getMyInfo.ts b/frontend/src/api/user/getMyInfo.ts new file mode 100644 index 00000000..6e4d070c --- /dev/null +++ b/frontend/src/api/user/getMyInfo.ts @@ -0,0 +1,7 @@ +import { api } from "@/api/config/axios.ts"; + +export const getMyInfo = async () => { + const response = await api.get("/api/user/my"); + + return response.data; +}; diff --git a/frontend/src/components/common/Animate/NotFound.tsx b/frontend/src/components/common/Animate/NotFound.tsx new file mode 100644 index 00000000..2e515c02 --- /dev/null +++ b/frontend/src/components/common/Animate/NotFound.tsx @@ -0,0 +1,38 @@ +import { DotLottiePlayer } from "@dotlottie/react-player"; +import NotFoundAnimation from "/assets/notfound.lottie"; +import { Link } from "react-router-dom"; + +interface NotFoundProps { + message?: string; + className?: string; + redirect?: { + path: string; + buttonText?: string; + }; +} +const NotFound = ({ message, className, redirect }: NotFoundProps) => { + return ( +
+ +

{message}

+ {redirect && ( + + {redirect.buttonText} + + )} +
+ ); +}; + +export default NotFound; diff --git a/frontend/src/components/common/Button/index.tsx b/frontend/src/components/common/Button/index.tsx index 66b008cf..28bccb61 100644 --- a/frontend/src/components/common/Button/index.tsx +++ b/frontend/src/components/common/Button/index.tsx @@ -15,7 +15,7 @@ const Button = ({ text, type, icon: Icon, onClick }: ButtonProps) => { return ( + ); }; export default CreateButton; diff --git a/frontend/src/components/common/Divider.tsx b/frontend/src/components/common/Divider.tsx index 2571daa5..c1cc717c 100644 --- a/frontend/src/components/common/Divider.tsx +++ b/frontend/src/components/common/Divider.tsx @@ -1,12 +1,21 @@ -const Divider = () => { +interface DividerProps { + message?: string; + isText?: boolean; +} + +const Divider = ({ message = "또는", isText = true }: DividerProps) => { return (
-
- 또는 -
+ {isText && ( +
+ + {message} + +
+ )}
); }; diff --git a/frontend/src/components/common/Error/ErrorBlock.tsx b/frontend/src/components/common/Error/ErrorBlock.tsx new file mode 100644 index 00000000..77246b56 --- /dev/null +++ b/frontend/src/components/common/Error/ErrorBlock.tsx @@ -0,0 +1,22 @@ +import { IoMdWarning } from "react-icons/io"; +import { AxiosError } from "axios"; + +interface ErrorBlockProps { + error: Error | AxiosError | null; + message?: string; +} + +const ErrorBlock = ({ error, message }: ErrorBlockProps) => { + return ( + error && ( +
+ +

+ {message ? message : "예기치 못한 오류가 발생했어요..!"} +

+
+ ) + ); +}; + +export default ErrorBlock; diff --git a/frontend/src/components/common/LoadingIndicator.tsx b/frontend/src/components/common/LoadingIndicator.tsx index a66d44a6..98cb8ba3 100644 --- a/frontend/src/components/common/LoadingIndicator.tsx +++ b/frontend/src/components/common/LoadingIndicator.tsx @@ -4,12 +4,16 @@ interface LoadingIndicator { loadingState: boolean; type?: "threeDots" | "spinner"; text?: string; + className?: string; + style?: React.CSSProperties; } const LoadingIndicator = ({ type = "threeDots", loadingState, text, + className, + style, }: LoadingIndicator) => { const render = () => { switch (type) { @@ -29,14 +33,14 @@ const LoadingIndicator = ({ autoplay={true} loop={true} speed={1.5} - style={{ width: 120, height: 120, opacity: 0.8 }} + style={style ?? { width: 120, height: 120, opacity: 0.8 }} /> ); } }; return ( loadingState && ( -
+
{render()} {text && (

{text}

diff --git a/frontend/src/components/common/Modal/index.tsx b/frontend/src/components/common/Modal/index.tsx index 011fc352..5aa7e695 100644 --- a/frontend/src/components/common/Modal/index.tsx +++ b/frontend/src/components/common/Modal/index.tsx @@ -1,3 +1,4 @@ +import { IoMdClose } from "react-icons/io"; import ModalTitle from "./Title"; interface UseModalReturn { @@ -44,9 +45,12 @@ const Modal = ({ return ( +
- -
- - -
- - ); -}; - -export default DefaultAuthFormContainer; diff --git a/frontend/src/components/mypage/ButtonSection.tsx b/frontend/src/components/mypage/ButtonSection.tsx index 0dea6750..b2ebf681 100644 --- a/frontend/src/components/mypage/ButtonSection.tsx +++ b/frontend/src/components/mypage/ButtonSection.tsx @@ -1,13 +1,15 @@ -import useModalStore from "@/stores/useModalStore"; import Button from "@components/common/Button"; -const ButtonSection = () => { - const { closeModal } = useModalStore(); +interface ButtonSectionProps { + closeModal: () => void; + onSubmit: () => void; +} +const ButtonSection = ({ closeModal, onSubmit }: ButtonSectionProps) => { return (
); }; diff --git a/frontend/src/components/mypage/PasswordInput.tsx b/frontend/src/components/mypage/PasswordInput.tsx new file mode 100644 index 00000000..47fb15db --- /dev/null +++ b/frontend/src/components/mypage/PasswordInput.tsx @@ -0,0 +1,41 @@ +import { IoEyeOffOutline, IoEyeOutline } from "react-icons/io5"; + +interface PasswordInputProps { + placeholder: string; + password: string; + showPassword: boolean; + setShowPassword: (show: boolean) => void; + onChange: (e: React.ChangeEvent) => void; +} + +const PasswordInput = ({ + placeholder, + password, + showPassword, + setShowPassword, + onChange, +}: PasswordInputProps) => { + return ( +
+ + +
+ ); +}; + +export default PasswordInput; diff --git a/frontend/src/components/mypage/ProfileIcon.tsx b/frontend/src/components/mypage/ProfileIcon.tsx index ce67b8e6..80c941fc 100644 --- a/frontend/src/components/mypage/ProfileIcon.tsx +++ b/frontend/src/components/mypage/ProfileIcon.tsx @@ -1,8 +1,14 @@ -const ProfileIcon = () => { +const ProfileIcon = ({ url }: { url: string }) => { return (
-
-
+ {url === "" ? ( + <> +
+
+ + ) : ( + 프로필 + )}
); }; diff --git a/frontend/src/components/mypage/QuestionList/QuestionItem/index.tsx b/frontend/src/components/mypage/QuestionList/QuestionItem/index.tsx index 9338a080..9cc79a38 100644 --- a/frontend/src/components/mypage/QuestionList/QuestionItem/index.tsx +++ b/frontend/src/components/mypage/QuestionList/QuestionItem/index.tsx @@ -40,7 +40,7 @@ const QuestionItem = ({ questionListId, type, page }: ItemProps) => { leftButton="취소하기" rightButton="삭제하기" type="red" - onLeftClick={() => { }} + onLeftClick={() => {}} onRightClick={deleteHandler} />
{
- {data?.categoryNames.map(category => ())} + {data?.categoryNames.map((category) => ( + + ))}
-

- {data?.title} -

+

{data?.title}

{type === "my" ? ( <> diff --git a/frontend/src/components/questions/QuestionsPreviewCard.tsx b/frontend/src/components/questions/QuestionsPreviewCard.tsx index 72d41dbe..1deab40f 100644 --- a/frontend/src/components/questions/QuestionsPreviewCard.tsx +++ b/frontend/src/components/questions/QuestionsPreviewCard.tsx @@ -1,4 +1,5 @@ import { FaStar, FaUsers } from "react-icons/fa6"; +import { Link } from "react-router-dom"; interface QuestionCardProps { id: number; @@ -7,21 +8,20 @@ interface QuestionCardProps { usage: number; isStarred?: boolean; category: string; - onClick: () => void; } const QuestionCard = ({ + id, title, questionCount, usage, isStarred = false, category, - onClick, }: QuestionCardProps) => { return ( -
@@ -35,11 +35,9 @@ const QuestionCard = ({ }`} />
-

{title}

-
{questionCount} @@ -50,7 +48,7 @@ const QuestionCard = ({ {usage}
-
+ ); }; diff --git a/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx b/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx index 2ddebccf..90b8a170 100644 --- a/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx +++ b/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx @@ -1,7 +1,7 @@ import SelectTitle from "@/components/common/SelectTitle"; -import CategorySelector from "@/components/common/CategorySelector"; import { options } from "./data"; import useQuestionFormStore from "@/stores/useQuestionFormStore"; +import Select from "@/components/common/Select"; const CategorySection = () => { const { category, setCategory } = useQuestionFormStore(); @@ -9,11 +9,11 @@ const CategorySection = () => { return (
-
); diff --git a/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/QuestionInput.tsx b/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/QuestionInput.tsx index d2f6746c..f3a77322 100644 --- a/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/QuestionInput.tsx +++ b/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/QuestionInput.tsx @@ -56,7 +56,7 @@ const QuestionInput = () => { rows={1} />
); diff --git a/frontend/src/components/questions/detail/QuestionList.tsx/QuestionItem.tsx b/frontend/src/components/questions/detail/QuestionList.tsx/QuestionItem.tsx index b427e8c0..1099858d 100644 --- a/frontend/src/components/questions/detail/QuestionList.tsx/QuestionItem.tsx +++ b/frontend/src/components/questions/detail/QuestionList.tsx/QuestionItem.tsx @@ -5,8 +5,8 @@ interface QuestionItemProps { const QuestionItem = ({ index, content }: QuestionItemProps) => { return ( -
-

Q{index + 1}

+
+

Q{index + 1}.

{content}

); diff --git a/frontend/src/components/questions/detail/QuestionList.tsx/index.tsx b/frontend/src/components/questions/detail/QuestionList.tsx/index.tsx index 5eafc6b7..01f9f658 100644 --- a/frontend/src/components/questions/detail/QuestionList.tsx/index.tsx +++ b/frontend/src/components/questions/detail/QuestionList.tsx/index.tsx @@ -1,5 +1,7 @@ import { useGetQuestionContent } from "@/hooks/api/useGetQuestionContent"; import QuestionItem from "./QuestionItem"; +import ErrorBlock from "@components/common/Error/ErrorBlock.tsx"; +import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; const QuestionList = ({ questionId }: { questionId: string }) => { const { @@ -8,8 +10,14 @@ const QuestionList = ({ questionId }: { questionId: string }) => { error, } = useGetQuestionContent(Number(questionId)); - if (isLoading) return
로딩 중
; - if (error) return
에러가 발생
; + if (isLoading) return ; + if (error) + return ( + + ); if (!question) return null; return ( diff --git a/frontend/src/components/questions/detail/QuestionTitle.tsx/index.tsx b/frontend/src/components/questions/detail/QuestionTitle.tsx/index.tsx index 57cde8a3..20930ac5 100644 --- a/frontend/src/components/questions/detail/QuestionTitle.tsx/index.tsx +++ b/frontend/src/components/questions/detail/QuestionTitle.tsx/index.tsx @@ -1,7 +1,8 @@ import { useGetQuestionContent } from "@/hooks/api/useGetQuestionContent"; import { MdEdit } from "react-icons/md"; import { RiDeleteBin6Fill } from "react-icons/ri"; -import { FaRegBookmark } from "react-icons/fa"; +import { FaRegBookmark, FaRegUser } from "react-icons/fa"; +import ErrorBlock from "@components/common/Error/ErrorBlock.tsx"; const QuestionTitle = ({ questionId }: { questionId: string }) => { const { @@ -10,27 +11,34 @@ const QuestionTitle = ({ questionId }: { questionId: string }) => { error, } = useGetQuestionContent(Number(questionId)); - if (isLoading) return
로딩 중
; - if (error) return
에러 발생
; + if (isLoading) return
; + if (error) + return ( + + ); if (!question) return null; return (

{question.title}

-
+
- +
+ 작성자 {question.username} • {question.contents.length}개의 질문 - +
{question.usage} diff --git a/frontend/src/components/session/CommonTools.tsx b/frontend/src/components/session/CommonTools.tsx new file mode 100644 index 00000000..efb7c312 --- /dev/null +++ b/frontend/src/components/session/CommonTools.tsx @@ -0,0 +1,166 @@ +import { + MdVideocam, + MdVideocamOff, + MdMic, + MdMicOff, + MdThumbUp, +} from "react-icons/md"; +import { IoChevronDownSharp } from "react-icons/io5"; +import Modal from "../common/Modal"; +import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent"; +import { useNavigate } from "react-router-dom"; +import useToast from "@/hooks/useToast"; +import useModal from "@/hooks/useModal"; +import useSocket from "@/hooks/useSocket"; + +interface CommonToolsProps { + handleVideoToggle: () => void; + handleMicToggle: () => void; + emitReaction: (reactionType: string) => void; + userVideoDevices: MediaDeviceInfo[]; + userAudioDevices: MediaDeviceInfo[]; + setSelectedVideoDeviceId: (deviceId: string) => void; + setSelectedAudioDeviceId: (deviceId: string) => void; + isVideoOn: boolean; + isMicOn: boolean; + videoLoading: boolean; + isHost: boolean; + roomId: string; +} + +const CommonTools = ({ + handleVideoToggle, + handleMicToggle, + emitReaction, + userVideoDevices, + userAudioDevices, + setSelectedVideoDeviceId, + setSelectedAudioDeviceId, + isVideoOn, + isMicOn, + videoLoading, + isHost, + roomId, +}: CommonToolsProps) => { + const navigate = useNavigate(); + const toast = useToast(); + const modal = useModal(); + const { socket } = useSocket(); + + const existHandler = () => { + socket?.emit(SESSION_EMIT_EVENT.LEAVE, { roomId }); + toast.success("메인 화면으로 이동합니다."); + navigate("/sessions"); + }; + + const destroyAndExitHandler = () => { + socket?.off(SESSION_EMIT_EVENT.FINISH); + socket?.emit(SESSION_EMIT_EVENT.FINISH, { roomId }); + toast.success("메인 화면으로 이동합니다."); + navigate("/sessions"); + }; + + const modalProps = isHost + ? { + title: "세션을 종료할까요?", + subtitle: "호스트 권한을 주지 않으면 모두 나가져요!", + leftButton: "호스트 권한 전달", + rightButton: "종료하기", + type: "red" as const, + onLeftClick: existHandler, + onRightClick: destroyAndExitHandler, + } + : { + title: "지금 나가면 다시 들어올 수 없어요!", + subtitle: "정말 종료하시겠어요?", + leftButton: "취소하기", + rightButton: "종료하기", + type: "red" as const, + onLeftClick: () => {}, + onRightClick: existHandler, + }; + + return ( + <> +
+
+ + + + + +
+
+ + + + + +
+ + + +
+ + ); +}; + +export default CommonTools; diff --git a/frontend/src/components/session/DisplayMediaStream.tsx b/frontend/src/components/session/DisplayMediaStream.tsx index b08c0b28..3cc5d579 100644 --- a/frontend/src/components/session/DisplayMediaStream.tsx +++ b/frontend/src/components/session/DisplayMediaStream.tsx @@ -24,7 +24,7 @@ const DisplayMediaStream = ({ autoPlay playsInline muted={isLocal} - className="h-full aspect-4-3" + className="w-full aspect-[4/3]" /> ); }; diff --git a/frontend/src/components/session/HostOnlyTools.tsx b/frontend/src/components/session/HostOnlyTools.tsx new file mode 100644 index 00000000..325a51bf --- /dev/null +++ b/frontend/src/components/session/HostOnlyTools.tsx @@ -0,0 +1,124 @@ +import { MdArrowForwardIos, MdArrowBackIosNew } from "react-icons/md"; +import { useEffect, useState } from "react"; +import ToolTip from "../common/ToolTip"; + +// 툴바에서 호스트만 사용가능 도구들 분리 +interface HostOnlyToolsProps { + isHost: boolean; + isInProgress: boolean; + stopStudySession: () => void; + startStudySession: () => void; + requestChangeIndex: (type: "next" | "prev") => void; + currentIndex: number; + maxQuestionLength: number; +} +const HostOnlyTools = ({ + isHost, + isInProgress, + stopStudySession, + startStudySession, + requestChangeIndex, + currentIndex, + maxQuestionLength, +}: HostOnlyToolsProps) => { + const [changeCooldown, setChangeCooldown] = useState(false); + const COOLDOWN_TIME = 2000; + + useEffect(() => { + if (!changeCooldown) return; + + const timeout = setTimeout(() => { + setChangeCooldown(false); + }, COOLDOWN_TIME); + + return () => { + clearTimeout(timeout); + }; + }, [changeCooldown]); + + return ( + isHost && ( + <> + {isInProgress ? ( +
+ +
+ ) : ( +
+ +
+ )} + {isInProgress && ( +
+ + + + + + +
+ )} + + ) + ); +}; + +export default HostOnlyTools; diff --git a/frontend/src/components/session/SessionHeader.tsx b/frontend/src/components/session/SessionHeader.tsx deleted file mode 100644 index e336ca9e..00000000 --- a/frontend/src/components/session/SessionHeader.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { RoomMetadata } from "@hooks/type/session"; -import { useEffect, useState } from "react"; - -interface SessionHeaderProps { - roomMetadata: RoomMetadata | null; - participantsCount: number; -} - -const SessionHeader = ({ - participantsCount, - roomMetadata, -}: SessionHeaderProps) => { - const [uptime, setUptime] = useState(0); - const SECOND = 1000; - useEffect(() => { - if (!roomMetadata?.inProgress) return; - const interval = setInterval(() => { - setUptime((prev) => prev + 1); - }, SECOND); - - return () => { - clearInterval(interval); - }; - }, [roomMetadata?.inProgress]); - - return ( -
- {roomMetadata?.title ? ( - <> - - {roomMetadata?.category} - -

{roomMetadata?.title}

- - {roomMetadata && - `(${participantsCount} / ${roomMetadata.maxParticipants})`} - - {roomMetadata.inProgress ? ( - - - 스터디 진행 중 - - {Math.floor(uptime / 60)}분 {uptime % 60}초 - - - ) : ( - - - 스터디 시작 전 - - )} - - ) : ( -

아직 세션에 참가하지 않았습니다.

- )} -
- ); -}; - -export default SessionHeader; diff --git a/frontend/src/components/session/Toolbar/CommonTools.tsx b/frontend/src/components/session/Toolbar/CommonTools.tsx deleted file mode 100644 index 3badd676..00000000 --- a/frontend/src/components/session/Toolbar/CommonTools.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - BsCameraVideo, - BsCameraVideoOff, - BsHandThumbsUp, - BsMic, - BsMicMute, -} from "react-icons/bs"; - -interface CommonToolsProps { - handleVideoToggle: () => void; - handleMicToggle: () => void; - emitReaction: (reactionType: string) => void; - userVideoDevices: MediaDeviceInfo[]; - userAudioDevices: MediaDeviceInfo[]; - setSelectedVideoDeviceId: (deviceId: string) => void; - setSelectedAudioDeviceId: (deviceId: string) => void; - isVideoOn: boolean; - isMicOn: boolean; - videoLoading: boolean; -} -const CommonTools = ({ - handleVideoToggle, - handleMicToggle, - emitReaction, - userVideoDevices, - userAudioDevices, - setSelectedVideoDeviceId, - setSelectedAudioDeviceId, - isVideoOn, - isMicOn, - videoLoading, -}: CommonToolsProps) => { - return ( - <> -
- - - - - -
- - ); -}; - -export default CommonTools; diff --git a/frontend/src/components/session/Toolbar/HostOnlyTools.tsx b/frontend/src/components/session/Toolbar/HostOnlyTools.tsx deleted file mode 100644 index 72f2ac74..00000000 --- a/frontend/src/components/session/Toolbar/HostOnlyTools.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { FaAngleLeft, FaAngleRight } from "react-icons/fa6"; -import { useEffect, useState } from "react"; - -// 툴바에서 호스트만 사용가능 도구들 분리 -interface HostOnlyToolsProps { - isHost: boolean; - isInProgress: boolean; - stopStudySession: () => void; - startStudySession: () => void; - requestChangeIndex: (type: "next" | "prev") => void; - currentIndex: number; - maxQuestionLength: number; -} -const HostOnlyTools = ({ - isHost, - isInProgress, - stopStudySession, - startStudySession, - requestChangeIndex, - currentIndex, - maxQuestionLength, -}: HostOnlyToolsProps) => { - const [changeCooldown, setChangeCooldown] = useState(false); - const COOLDOWN_TIME = 2000; - - useEffect(() => { - if (!changeCooldown) return; - - const timeout = setTimeout(() => { - setChangeCooldown(false); - }, COOLDOWN_TIME); - - return () => { - clearTimeout(timeout); - }; - }, [changeCooldown]); - - return ( - isHost && ( - <> - {isInProgress ? ( -
- -
- ) : ( -
- -
- )} - {isInProgress && ( -
- - -
- )} - - ) - ); -}; - -export default HostOnlyTools; diff --git a/frontend/src/components/session/VideoContainer.tsx b/frontend/src/components/session/VideoContainer.tsx index b5c36cc2..06e6fb8c 100644 --- a/frontend/src/components/session/VideoContainer.tsx +++ b/frontend/src/components/session/VideoContainer.tsx @@ -6,6 +6,8 @@ import { } from "react-icons/bs"; import DisplayMediaStream from "./DisplayMediaStream.tsx"; import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; +import VideoProfileOverlay from "@components/session/VideoProfileOverlay.tsx"; +import VideoReactionBox from "@components/session/VideoReactionBox.tsx"; interface VideoContainerProps { nickname: string; @@ -15,8 +17,34 @@ interface VideoContainerProps { reaction: string; stream: MediaStream; videoLoading?: boolean; + videoCount: number; } +const getVideoLayoutClass = (count: number) => { + switch (count) { + case 1: + return "w-[calc(min(100%,((100vh-140px)*(4/3))))]"; + case 2: + return `w-[calc(min(100%,((100vh-146px)*(2/3))))] + sm:w-[calc(min(calc(50%-0.375rem),((100vh-140px)*(4/3))))] + `; + case 3: + return `w-[calc(min(100%,((100vh-152px)*(4/9))))] + md:w-[calc(min(calc(50%-0.75rem),((100vh-146px)*(2/3))))] + 2xl:w-[calc(min(calc(33.3%-0.75rem),((100vh-140px)*(4/3))))] + `; + case 4: + return `w-[calc(min(100%,((100vh-158px)*(1/3))))] + md:w-[calc(min(calc(50%-0.375rem),((100vh-146px)*(2/3))))] + `; + case 5: + return `w-[calc(min(100%,((100vh-164px)*(4/15))))] + xs:w-[calc(min(calc(50%-0.375rem),((100vh-152px)*(4/9))))] + 2xl:w-[calc(min(calc(33.3%-0.75rem),((100vh-146px)*(2/3))))] + `; + } +}; + const VideoContainer = ({ nickname, isMicOn, @@ -25,6 +53,7 @@ const VideoContainer = ({ reaction, stream, videoLoading, + videoCount, }: VideoContainerProps) => { const renderReaction = (reactionType: string) => { switch (reactionType) { @@ -40,7 +69,7 @@ const VideoContainer = ({ return isMicOn ? ( ) : ( - + ); }; @@ -48,17 +77,21 @@ const VideoContainer = ({ return isVideoOn ? ( ) : ( - + ); }; + const localNickName = isLocal ? "text-semibold-r" : "text-medium-m"; + return ( -
-
+
+
-

- {isLocal && "Me"} {nickname} +

+ {isVideoOn && nickname}

{renderMicIcon()} @@ -75,22 +108,13 @@ const VideoContainer = ({
)}
- { -
- {renderReaction(reaction)} -
- } + +
); }; diff --git a/frontend/src/components/session/VideoProfileOverlay.tsx b/frontend/src/components/session/VideoProfileOverlay.tsx new file mode 100644 index 00000000..8e40e900 --- /dev/null +++ b/frontend/src/components/session/VideoProfileOverlay.tsx @@ -0,0 +1,41 @@ +interface VideoProfileOverlayProps { + isVideoOn: boolean; + videoLoading: boolean; + nickname: string; + profileImage?: string; +} + +const VideoProfileOverlay = ({ + isVideoOn, + videoLoading, + nickname, + profileImage, +}: VideoProfileOverlayProps) => { + return ( + !isVideoOn && + !videoLoading && ( +
+ {profileImage ? ( +
+ {nickname +
+ ) : ( + {nickname} + )} +
+ ) + ); +}; +export default VideoProfileOverlay; diff --git a/frontend/src/components/session/VideoReactionBox.tsx b/frontend/src/components/session/VideoReactionBox.tsx new file mode 100644 index 00000000..074494b7 --- /dev/null +++ b/frontend/src/components/session/VideoReactionBox.tsx @@ -0,0 +1,28 @@ +interface VideoReactionBoxProps { + reaction: string; + renderReaction: (reactionType: string) => string; +} + +const VideoReactionBox = ({ + reaction, + renderReaction, +}: VideoReactionBoxProps) => { + return ( +
+ {renderReaction(reaction)} +
+ ); +}; + +export default VideoReactionBox; diff --git a/frontend/src/components/sessions/SessionCard.tsx b/frontend/src/components/sessions/SessionCard.tsx deleted file mode 100644 index f6049387..00000000 --- a/frontend/src/components/sessions/SessionCard.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { FaUserGroup } from "react-icons/fa6"; -import { IoArrowForwardSharp } from "react-icons/io5"; -interface Props { - category?: string; - title: string; - host: string; - inProgress: boolean; - participant: number; - maxParticipant: number; - questionListId: number; - onEnter: () => void; -} - -const SessionCard = ({ - category = "None", - title, - host, - participant, - maxParticipant, - inProgress, - questionListId, - onEnter, -}: Props) => { - return ( -
  • -
    - - {category} - -

    {title}

    -

    - 질문지인데 누르면 질문 리스트를 볼 수 있음 {questionListId} -

    -
    -
    - {host} • - - {" "} - 참여자 - {participant}/{maxParticipant}명 - -
    - {!inProgress && ( - - )} -
    -
    -
  • - ); -}; - -export default SessionCard; diff --git a/frontend/src/components/sessions/create/SessionForm/CategorySection/index.tsx b/frontend/src/components/sessions/create/SessionForm/CategorySection/index.tsx index 078fda6c..8a1930da 100644 --- a/frontend/src/components/sessions/create/SessionForm/CategorySection/index.tsx +++ b/frontend/src/components/sessions/create/SessionForm/CategorySection/index.tsx @@ -1,6 +1,6 @@ import SelectTitle from "@/components/common/SelectTitle"; import useSessionFormStore from "@/stores/useSessionFormStore"; -import CategorySelector from "@/components/common/CategorySelector"; +import Select from "@/components/common/Select"; const options = [ { @@ -19,11 +19,11 @@ const CategorySection = () => { return (
    -
    ); diff --git a/frontend/src/hooks/__test__/useSession.test.ts b/frontend/src/hooks/__test__/useSession.test.ts index c140208b..dccca483 100644 --- a/frontend/src/hooks/__test__/useSession.test.ts +++ b/frontend/src/hooks/__test__/useSession.test.ts @@ -1,8 +1,5 @@ import { renderHook } from "@testing-library/react"; -import { useSession } from "@hooks/session/useSession"; import useSocketStore from "@stores/useSocketStore"; -import useMediaDevices from "@hooks/session/useMediaDevices"; -import usePeerConnection from "@hooks/session/usePeerConnection"; import { useNavigate } from "react-router-dom"; import { act } from "react"; import { @@ -18,6 +15,9 @@ import { SESSION_LISTEN_EVENT, } from "@/constants/WebSocket/SessionEvent"; import { SIGNAL_LISTEN_EVENT } from "@/constants/WebSocket/SignalingEvent"; +import useMediaDevices from "@/pages/SessionPage/hooks/useMediaDevices"; +import usePeerConnection from "@/pages/SessionPage/hooks/usePeerConnection"; +import { useSession } from "@/pages/SessionPage/hooks/useSession"; const REACTION_DURATION = 3000; diff --git a/frontend/src/hooks/api/useGetQuestionContent.ts b/frontend/src/hooks/api/useGetQuestionContent.ts index 6a3a6cf2..6d479497 100644 --- a/frontend/src/hooks/api/useGetQuestionContent.ts +++ b/frontend/src/hooks/api/useGetQuestionContent.ts @@ -27,5 +27,5 @@ export const useGetQuestionContent = (questionListId: number) => { data, isLoading, error, - } + }; }; diff --git a/frontend/src/hooks/api/useGetQuestionList.ts b/frontend/src/hooks/api/useGetQuestionList.ts index 4968950d..62fb4e55 100644 --- a/frontend/src/hooks/api/useGetQuestionList.ts +++ b/frontend/src/hooks/api/useGetQuestionList.ts @@ -1,22 +1,30 @@ -import { getQuestionList } from "@/api/question-list/getQuestionList"; +import { + getQuestionList, + getQuestionListWithCategory, +} from "@/api/question-list/getQuestionList"; import { useQuery } from "@tanstack/react-query"; interface UseGetQuestionListProps { - page: number; - limit: number; + page?: number; + limit?: number; + category: string; } -export const useCreateQuestionList = ({ +export const useQuestionList = ({ page, limit, + category = "전체", }: UseGetQuestionListProps) => { const { data, isLoading, error } = useQuery({ - queryKey: ["questions", page, limit], - queryFn: () => getQuestionList({ page, limit }), + queryKey: ["questions", page, limit, category], + queryFn: () => + category !== "전체" + ? getQuestionListWithCategory({ categoryName: category, page, limit }) + : getQuestionList({ page, limit }), }); return { - questions: data, + data, isLoading, error, }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 0ee4fc3d..6262695b 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -49,8 +49,48 @@ const useAuth = () => { }; const guestLogIn = () => { - setNickname("게스트 사용자"); + const randomNickname = createGuestRandomNickname(); + setNickname(randomNickname); guestLogin(); + return randomNickname; + }; + + const firstNames = [ + "신나는", + "즐거운", + "행복한", + "멋있는", + "귀여운", + "활기찬", + "열정적인", + "용감한", + "영리한", + "현명한", + "따뜻한", + "차가운", + ]; + + const secondNames = [ + "판다", + "고양이", + "강아지", + "토끼", + "사자", + "기린", + "코끼리", + "하마", + "펭귄", + "여우", + "눈사람", + ]; + + const createGuestRandomNickname = () => { + // 각 배열에서 랜덤한 인덱스 선택 + const randomFirstIndex = Math.floor(Math.random() * firstNames.length); + const randomSecondIndex = Math.floor(Math.random() * secondNames.length); + + // 선택된 단어들을 조합 + return `${firstNames[randomFirstIndex]} ${secondNames[randomSecondIndex]}${Math.floor(Math.random() * 1000)}`; }; return { diff --git a/frontend/src/index.css b/frontend/src/index.css index 31fb23af..1b52e920 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -21,9 +21,37 @@ } } + + :root { --bg-color-default: #fafafa; --text-default: #101010; + /* Gray Colors */ + --color-gray-white: #FFFFFF; + --color-gray-50: #FAFAFA; + --color-gray-100: #EDEDED; + --color-gray-200: #E1E1E1; + --color-gray-300: #D9D9D9; + --color-gray-400: #6C6C6C; + --color-gray-500: #5E5E5E; + --color-gray-600: #3F3F3F; + --color-gray-black: #171717; + + /* Green Colors */ + --color-green-50: #F1FBF7; + --color-green-100: #01BF6F; + --color-green-200: #01AC64; + --color-green-300: #019959; + --color-green-400: #018F53; + --color-green-500: #017343; + --color-green-600: #005632; + --color-green-700: #004327; + + /* Point Colors */ + --color-point-1: #F04040; + --color-point-2: #DFDDD5; + --color-point-3: #2572E6; + line-height: 1.5; font-weight: 400; @@ -40,8 +68,28 @@ .dark { --bg-color-default: #141414; --text-default: #ffffff; + --color-gray-white: #3E3E3E; + --color-green-100: #3AF2A4; + --color-green-200: #3AF2A4; + --color-green-300: #3AF2A4; + --color-green-400: #3AF2A4; + --color-green-500: #3AF2A4; +} + +input { + background-color: white; +} + +.dark input { + color: white; + background-color: #3E3E3E; } +.dark input::placeholder { + color: #B0B0B0; +} + + .aspect-4-3 { aspect-ratio: 4 / 3; } diff --git a/frontend/src/pages/CreateQuestionPage.tsx b/frontend/src/pages/CreateQuestionPage.tsx index 3a0cf147..55dc7ec2 100644 --- a/frontend/src/pages/CreateQuestionPage.tsx +++ b/frontend/src/pages/CreateQuestionPage.tsx @@ -1,37 +1,26 @@ import QuestionForm from "@/components/questions/create/QuestionForm"; import { IoArrowBackSharp } from "react-icons/io5"; -import { useNavigate } from "react-router-dom"; -import useAuth from "@hooks/useAuth.ts"; -import { useEffect } from "react"; -import useToast from "@hooks/useToast.ts"; +import { Link } from "react-router-dom"; +import SidebarPageLayout from "@components/layout/SidebarPageLayout.tsx"; const CreateQuestionPage = () => { - const navigate = useNavigate(); - const { isLoggedIn } = useAuth(); - const toast = useToast(); - - useEffect(() => { - if (!isLoggedIn) { - toast.error("로그인이 필요한 서비스입니다."); - navigate("/questions"); - } - }, [isLoggedIn]); - return ( -
    - -

    새로운 면접 질문 리스트 만들기

    -

    - 면접 스터디를 위한 새로운 질문지를 생성합니다. -

    - -
    + +
    + + + 면접 리스트로 돌아가기 + +

    새로운 면접 질문 리스트 만들기

    +

    + 면접 스터디를 위한 새로운 질문지를 생성합니다. +

    + +
    +
    ); }; diff --git a/frontend/src/pages/Login/LoginPage.tsx b/frontend/src/pages/Login/LoginPage.tsx index 8ea8e263..6d4878d7 100644 --- a/frontend/src/pages/Login/LoginPage.tsx +++ b/frontend/src/pages/Login/LoginPage.tsx @@ -1,16 +1,14 @@ import useAuth from "@hooks/useAuth.ts"; import { useNavigate } from "react-router-dom"; -import useToast from "@hooks/useToast.ts"; import DrawingSnowman from "@components/common/Animate/DrawingSnowman.tsx"; -import Divider from "@components/common/Divider.tsx"; -import OAuthContainer from "@components/login/OAuthContainer.tsx"; -import DefaultAuthFormContainer from "@components/login/DefaultAuthFormContainer.tsx"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import LoginTitle from "@/pages/Login/view/LoginTitle.tsx"; +import LoginForm from "@/pages/Login/view/LoginForm.tsx"; const LoginPage = () => { - const { isLoggedIn, guestLogIn } = useAuth(); + const { isLoggedIn } = useAuth(); const navigate = useNavigate(); - const toast = useToast(); + const [isSignUp, setIsSignUp] = useState(false); useEffect(() => { if (isLoggedIn) { @@ -18,31 +16,6 @@ const LoginPage = () => { } }, [isLoggedIn]); - const handleOAuthLogin = (provider: "github" | "guest") => { - if (provider === "github") { - // 깃허브 로그인 - window.location.assign( - `https://github.com/login/oauth/authorize?client_id=${import.meta.env.VITE_OAUTH_GITHUB_ID}&redirect_uri=${import.meta.env.VITE_OAUTH_GITHUB_CALLBACK}` - ); - } else if (provider === "guest") { - // 게스트 로그인 - guestLogIn(); - toast.success("게스트로 로그인되었습니다."); - navigate("/"); - } - }; - - const handleDefaultLogin = (e: React.MouseEvent) => { - try { - e.preventDefault(); - toast.error( - "일반 로그인은 현재 지원되지 않습니다. Github나 게스트 로그인을 이용해주세요." - ); - } catch (err) { - console.error("로그인 도중 에러", err); - } - }; - return (
    @@ -51,19 +24,9 @@ const LoginPage = () => {
    -
    -

    - Preview -

    -
    -
    - - - - -
    +
    + +
    diff --git a/frontend/src/pages/Login/hooks/useValidate.ts b/frontend/src/pages/Login/hooks/useValidate.ts new file mode 100644 index 00000000..6882c1e7 --- /dev/null +++ b/frontend/src/pages/Login/hooks/useValidate.ts @@ -0,0 +1,177 @@ +import useAuth from "@hooks/useAuth.ts"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import useToast from "@hooks/useToast.ts"; +import axios, { isAxiosError } from "axios"; + +interface UseValidateProps { + setIsSignUp: (isSignUp: boolean) => void; +} + +const useValidate = ({ setIsSignUp }: UseValidateProps) => { + const auth = useAuth(); + const navigate = useNavigate(); + const toast = useToast(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [passwordCheck, setPasswordCheck] = useState(""); + const [nickname, setNickname] = useState(""); + const [loading, setLoading] = useState(false); + + const handleLogin = async () => { + try { + setLoading(true); + const response = await axios.post("/api/auth/login", { + userId: username, + password: password, + }); + + const { success = false } = response.data; + + if (success) { + toast.success("로그인에 성공했습니다."); + auth.logIn(); + auth.setNickname(nickname); + navigate("/"); + } + } catch (err) { + if (isAxiosError(err)) { + const { response } = err; + if (response?.status === 401) { + toast.error("아이디 또는 비밀번호가 일치하지 않습니다."); + } else { + toast.error("로그인에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + } else console.error("로그인 도중 에러", err); + } finally { + setLoading(false); + } + }; + + const validate = () => { + const errors: string[] = []; + + // 아이디 검증 + if (!username) { + errors.push("아이디는 필수입니다."); + } else { + if (username.length < 4) { + errors.push("아이디는 최소 4글자 이상이어야 합니다."); + } + if (username.length > 20) { + errors.push("아이디는 20글자 이하여야 합니다."); + } + if (!/^[a-zA-Z0-9_]+$/.test(username)) { + errors.push( + "아이디는 영문자, 숫자, 언더스코어(_)만 사용할 수 있습니다." + ); + } + } + + // 비밀번호 검증 + if (!password) { + errors.push("비밀번호는 필수입니다."); + } else { + if (password.length < 7) { + errors.push("비밀번호는 최소 7글자 이상이어야 합니다."); + } + if (password.length > 20) { + errors.push("비밀번호는 20글자 이하여야 합니다."); + } + if (!/[a-z]/.test(password)) { + errors.push("비밀번호는 최소 1개의 소문자를 포함해야 합니다."); + } + if (!/[0-9]/.test(password)) { + errors.push("비밀번호는 최소 1개의 숫자를 포함해야 합니다."); + } + if (password.includes(username)) { + errors.push("비밀번호에 아이디을 포함할 수 없습니다."); + } + } + + // 비밀번호 확인 검증 + if (!passwordCheck) { + errors.push("비밀번호 확인은 필수입니다."); + } else { + if (password !== passwordCheck) { + errors.push("비밀번호가 일치하지 않습니다."); + } + } + + // 닉네임 검증 + if (!nickname) { + errors.push("닉네임은 필수입니다."); + } else { + if (nickname.length < 2) { + errors.push("닉네임은 최소 2글자 이상이어야 합니다."); + } + if (nickname.length > 10) { + errors.push("닉네임은 10글자 이하여야 합니다."); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + }; + + const handleSignUp = async () => { + try { + setLoading(true); + const { isValid, errors } = validate(); + + if (errors.length > 0) { + errors.forEach((error) => { + toast.error(error); + }); + return; + } + + if (isValid) { + const response = await axios.post("/api/user/signup", { + id: username, + password: password, + nickname: nickname, + }); + + if (response.data.status) { + toast.success("회원가입에 성공했습니다. 로그인해주세요."); + setIsSignUp(false); + } else { + const { code } = response.data; + switch (code) { + case "DUPLICATE_NICKNAME": + toast.error("이미 존재하는 닉네임입니다."); + break; + case "DUPLICATE_ID": + toast.error("이미 존재하는 아이디입니다."); + break; + default: + toast.error( + "회원가입에 실패했습니다. 잠시 후 다시 시도해주세요." + ); + break; + } + } + } + } catch (err) { + console.error("회원가입 도중 에러", err); + } finally { + setLoading(false); + } + }; + + return { + setUsername, + setPassword, + setPasswordCheck, + setNickname, + loading, + handleLogin, + handleSignUp, + }; +}; + +export default useValidate; diff --git a/frontend/src/pages/Login/view/DefaultAuthFormContainer.tsx b/frontend/src/pages/Login/view/DefaultAuthFormContainer.tsx new file mode 100644 index 00000000..b1ae4fbc --- /dev/null +++ b/frontend/src/pages/Login/view/DefaultAuthFormContainer.tsx @@ -0,0 +1,112 @@ +import useToast from "@hooks/useToast.ts"; +import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; +import useValidate from "@/pages/Login/hooks/useValidate.ts"; + +interface DefaultAuthFormContainerProps { + isSignUp: boolean; + setIsSignUp: (isSignUp: boolean) => void; +} + +const DefaultAuthFormContainer = ({ + isSignUp, + setIsSignUp, +}: DefaultAuthFormContainerProps) => { + const toast = useToast(); + const { + handleSignUp, + handleLogin, + loading, + setNickname, + setUsername, + setPassword, + setPasswordCheck, + } = useValidate({ setIsSignUp }); + + const handleDefaultLogin = async (e: React.MouseEvent) => { + try { + e.preventDefault(); + if (isSignUp) await handleSignUp(); + else await handleLogin(); + } catch (err) { + console.error("로그인 도중 에러", err); + } + }; + + return ( + <> +
    + setUsername(e.target.value)} + /> +
    + +
    + setPassword(e.target.value)} + /> +
    + + {isSignUp && ( + <> +
    + setPasswordCheck(e.target.value)} + /> +
    +
    + setNickname(e.target.value)} + /> +
    + + )} + + + +
    + + +
    + + ); +}; + +export default DefaultAuthFormContainer; diff --git a/frontend/src/pages/Login/view/LoginForm.tsx b/frontend/src/pages/Login/view/LoginForm.tsx new file mode 100644 index 00000000..202e92b1 --- /dev/null +++ b/frontend/src/pages/Login/view/LoginForm.tsx @@ -0,0 +1,21 @@ +import DefaultAuthFormContainer from "@/pages/Login/view/DefaultAuthFormContainer.tsx"; +import Divider from "@components/common/Divider.tsx"; +import OAuthContainer from "@/pages/Login/view/OAuthContainer.tsx"; + +interface LoginFormProps { + signUp: boolean; + isSignUp: (value: ((prevState: boolean) => boolean) | boolean) => void; +} +const LoginForm = ({ signUp, isSignUp }: LoginFormProps) => { + return ( +
    +
    + + + + +
    + ); +}; + +export default LoginForm; diff --git a/frontend/src/pages/Login/view/LoginTitle.tsx b/frontend/src/pages/Login/view/LoginTitle.tsx new file mode 100644 index 00000000..dc240be0 --- /dev/null +++ b/frontend/src/pages/Login/view/LoginTitle.tsx @@ -0,0 +1,17 @@ +interface LoginTitleProps { + isSignUp: boolean; +} + +const LoginTitle = ({ isSignUp }: LoginTitleProps) => { + return ( + !isSignUp && ( +

    + Preview +

    + ) + ); +}; + +export default LoginTitle; diff --git a/frontend/src/components/login/OAuthContainer.tsx b/frontend/src/pages/Login/view/OAuthContainer.tsx similarity index 50% rename from frontend/src/components/login/OAuthContainer.tsx rename to frontend/src/pages/Login/view/OAuthContainer.tsx index bada12ea..638ec196 100644 --- a/frontend/src/components/login/OAuthContainer.tsx +++ b/frontend/src/pages/Login/view/OAuthContainer.tsx @@ -1,10 +1,29 @@ import { FaGithub, FaRegUserCircle } from "react-icons/fa"; +import useToast from "@hooks/useToast.ts"; +import { useNavigate } from "react-router-dom"; +import useAuth from "@hooks/useAuth.ts"; -interface OAuthContainerProps { - handleOAuthLogin: (provider: "github" | "guest") => void; -} +const OAuthContainer = () => { + const toast = useToast(); + const navigate = useNavigate(); + const { guestLogIn } = useAuth(); + + const handleOAuthLogin = (provider: "github" | "guest") => { + if (provider === "github") { + // 깃허브 로그인 + window.location.assign( + `https://github.com/login/oauth/authorize?client_id=${import.meta.env.VITE_OAUTH_GITHUB_ID}&redirect_uri=${import.meta.env.VITE_OAUTH_GITHUB_CALLBACK}` + ); + } else if (provider === "guest") { + // 게스트 로그인 + const nickname = guestLogIn(); + toast.success( + "게스트로 로그인되었습니다. 환영합니다. " + nickname + "님!" + ); + navigate("/"); + } + }; -const OAuthContainer = ({ handleOAuthLogin }: OAuthContainerProps) => { return ( <>
    -

    {nickname}

    +

    {user?.nickname}

    ); diff --git a/frontend/src/pages/MyPage/view/ProfileEditModal.tsx b/frontend/src/pages/MyPage/view/ProfileEditModal.tsx index 7ad8b5c2..b63b24ca 100644 --- a/frontend/src/pages/MyPage/view/ProfileEditModal.tsx +++ b/frontend/src/pages/MyPage/view/ProfileEditModal.tsx @@ -1,7 +1,10 @@ import TitleInput from "@components/common/TitleInput"; import { IoMdClose } from "react-icons/io"; import ButtonSection from "@components/mypage/ButtonSection"; -import useAuth from "@hooks/useAuth"; +import { useEffect, useState } from "react"; +import useToast from "@/hooks/useToast"; +import { useUserStore } from "@/stores/useUserStore"; +import PasswordInput from "../../../components/mypage/PasswordInput"; interface UseModalReturn { dialogRef: React.RefObject; @@ -10,24 +13,140 @@ interface UseModalReturn { closeModal: () => void; } +interface EditForm { + avatarUrl: string; + nickname: string; + password: { + original: string; + newPassword: string; + }; +} + const ProfileEditModal = ({ - modal: { dialogRef, isOpen, closeModal }, + modal: { dialogRef, closeModal }, }: { modal: UseModalReturn; }) => { - const { nickname } = useAuth(); + const toast = useToast(); + const [showOriginalPassword, setShowOriginalPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [originalPassword, setOriginalPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [nickname, setNickname] = useState(""); + const user = useUserStore((state) => state.user); + const { editMyInfo } = useUserStore(); + + const [formData, setFormData] = useState({ + avatarUrl: user?.avatarUrl || "", + nickname: user?.nickname || "", + password: { + original: "", + newPassword: "", + }, + }); + + useEffect(() => { + const handleDialogClose = () => { + setShowOriginalPassword(false); + setShowNewPassword(false); + setOriginalPassword(""); + setNewPassword(""); + setNickname(user ? user.nickname : ""); + }; + + const dialogElement = dialogRef.current; + dialogElement?.addEventListener("close", handleDialogClose); + setNickname(user ? user.nickname : ""); + + return () => { + dialogElement?.removeEventListener("close", handleDialogClose); + }; + }, [dialogRef, user]); + + const resetModal = () => { + if (dialogRef.current) { + dialogRef.current.close(); + } + closeModal(); + }; const handleMouseDown = (e: React.MouseEvent) => { if (e.target === dialogRef.current) { - closeModal(); + resetModal(); } }; - const closeHandler = () => { - closeModal(); + const handleClose = () => { + resetModal(); + }; + + const handleChangeNickname = (e: React.ChangeEvent) => { + setFormData((prev) => ({ + ...prev, + nickname: e.target.value, + })); + + setNickname(e.target.value); }; - if (!isOpen) return null; + const handlePasswordChange = ( + field: "original" | "newPassword", + value: string + ) => { + setFormData((prev) => ({ + ...prev, + password: { + ...prev.password!, + [field]: value, + }, + })); + }; + + const handleSubmit = async () => { + if ( + user?.loginType === "native" && + formData.password.newPassword && + formData.password.original + ) { + if ( + formData.nickname.trim().length < 2 || + formData.password.newPassword.trim().length < 7 || + formData.password.original.trim().length < 7 + ) { + toast.error("올바른 값을 입력해주세요."); + return; + } else if (formData.password.newPassword === formData.password.original) { + toast.error("기존 비밀번호와 같은 값을 입력했습니다."); + return; + } else if ( + !/[a-z]/.test(formData.password.newPassword) || + !/[0-9]/.test(formData.password.newPassword) + ) { + toast.error("비밀번호에 최소 하나의 숫자와 소문자를 넣어야합니다."); + return; + } + } else if ( + user?.loginType === "native" && + ((formData.password.newPassword && !formData.password.original) || + (!formData.password.newPassword && formData.password.original)) + ) { + toast.error("비밀번호 변경을 위해 둘 다 입력해주세요."); + return; + } else { + if (formData.nickname === user?.nickname) { + toast.error("변경사항이 없습니다."); + return; + } + } + + try { + await editMyInfo(formData); + toast.success("회원 정보가 변경되었습니다."); + resetModal(); + } catch (error) { + toast.error("회원 정보 변경에 실패하였습니다."); + } + }; return (

    회원 정보 수정

    -
    @@ -50,23 +169,43 @@ const ProfileEditModal = ({ {}} + onChange={handleChangeNickname} minLength={2} />

    비밀번호 변경

    - + {user?.loginType === "native" ? ( + <> + ) => { + handlePasswordChange("original", e.target.value); + setOriginalPassword(e.target.value); + }} + /> + ) => { + handlePasswordChange("newPassword", e.target.value); + setNewPassword(e.target.value); + }} + /> + + ) : ( +

    + 일반 로그인 외에는 비밀번호를 변경할 수 없습니다. +

    + )}
    - +
    ); }; diff --git a/frontend/src/pages/MyPage/view/QuestionSection.tsx b/frontend/src/pages/MyPage/view/QuestionSection.tsx index 0b3e8938..9080f4f5 100644 --- a/frontend/src/pages/MyPage/view/QuestionSection.tsx +++ b/frontend/src/pages/MyPage/view/QuestionSection.tsx @@ -19,12 +19,12 @@ const QuestionSection = () => { data: myData, isLoading: isMyListLoading, error: myListError, - } = useGetMyQuestionList({ limit: 8 }); + } = useGetMyQuestionList({ page: myListPage, limit: 8 }); const { data: scrapData, isLoading: isScrapListLoading, error: scrapListError, - } = useGetScrapQuestionList({ limit: 8 }); + } = useGetScrapQuestionList({ page: savedListPage, limit: 8 }); const isLoading = tab === "myList" ? isMyListLoading : isScrapListLoading; const error = tab === "myList" ? myListError : scrapListError; diff --git a/frontend/src/pages/MyPage/view/index.tsx b/frontend/src/pages/MyPage/view/index.tsx index 6cf950e5..8950ab9f 100644 --- a/frontend/src/pages/MyPage/view/index.tsx +++ b/frontend/src/pages/MyPage/view/index.tsx @@ -5,19 +5,15 @@ import Profile from "@/pages/MyPage/view/Profile"; import QuestionSection from "@/pages/MyPage/view/QuestionSection"; import useModal from "@/hooks/useModal"; -interface MyPageViewProps { - nickname: string; -} - -const MyPageView = ({ nickname }: MyPageViewProps) => { +const MyPageView = () => { const modal = useModal(); return ( - +
    - +
    diff --git a/frontend/src/pages/QuestionDetailPage.tsx b/frontend/src/pages/QuestionDetailPage.tsx deleted file mode 100644 index 9bbab5c2..00000000 --- a/frontend/src/pages/QuestionDetailPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useNavigate, useParams } from "react-router-dom"; -import Sidebar from "@/components/common/Sidebar"; -import { sectionWithSidebar } from "@/constants/LayoutConstant.ts"; -import QuestionTitle from "@/components/questions/detail/QuestionTitle.tsx"; -import QuestionList from "@/components/questions/detail/QuestionList.tsx"; -import { useGetQuestionContent } from "@/hooks/api/useGetQuestionContent"; -import ButtonSection from "@/components/questions/detail/ButtonSection.tsx"; -import { useEffect } from "react"; - -const QuestionDetailPage = () => { - const navigate = useNavigate(); - const { questionId } = useParams(); - - const { - data: question, - isLoading, - error, - } = useGetQuestionContent(Number(questionId!)); - - useEffect(() => { - if (!questionId) { - navigate("/questions"); - } - }, [questionId, navigate]); - - if (isLoading) return
    로딩 중
    ; - if (error) return
    에러가 발생
    ; - if (!question) return null; - - return ( -
    - -
    -
    - - - -
    -
    -
    - ); -}; - -export default QuestionDetailPage; diff --git a/frontend/src/pages/QuestionDetailPage/QuestionDetailPage.tsx b/frontend/src/pages/QuestionDetailPage/QuestionDetailPage.tsx new file mode 100644 index 00000000..adbe5e4a --- /dev/null +++ b/frontend/src/pages/QuestionDetailPage/QuestionDetailPage.tsx @@ -0,0 +1,79 @@ +import { useNavigate, useParams } from "react-router-dom"; +import QuestionTitle from "@components/questions/detail/QuestionTitle.tsx"; +import QuestionList from "@components/questions/detail/QuestionList.tsx"; +import { useGetQuestionContent } from "@hooks/api/useGetQuestionContent.ts"; +import ButtonSection from "@components/questions/detail/ButtonSection.tsx"; +import { useEffect, useState } from "react"; +import SidebarPageLayout from "@components/layout/SidebarPageLayout.tsx"; +import { + deleteScrapQuestionList, + postScrapQuestionList, +} from "@/pages/QuestionDetailPage/api/scrapAPI.ts"; +import useToast from "@hooks/useToast.ts"; +import ErrorBlock from "@components/common/Error/ErrorBlock.tsx"; +import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; + +const QuestionDetailPage = () => { + const navigate = useNavigate(); + const { questionId } = useParams(); + const [isScrapped, setIsScrapped] = useState(false); + // TODO: isScrapped 상태를 서버에서 가져오는 로직이 추가되면 해당 로직을 추가 + const toast = useToast(); + + const { + data: question, + isLoading, + error, + } = useGetQuestionContent(Number(questionId!)); + + useEffect(() => { + if (!questionId) { + navigate("/questions"); + } + }, [questionId, navigate]); + + const shareQuestionList = () => { + if (question) { + navigator.clipboard.writeText(window.location.href); + toast.success(`${question.title} 링크가 복사되었습니다.`); + } + }; + + return ( + +
    +
    + + + + + {question && ( + { + if (await postScrapQuestionList(questionId!)) { + setIsScrapped(true); + } + }} + unScrapQuestionList={async () => { + if (await deleteScrapQuestionList(questionId!)) { + setIsScrapped(false); + } + }} + shareQuestionList={shareQuestionList} + /> + )} +
    +
    +
    + ); +}; + +export default QuestionDetailPage; diff --git a/frontend/src/pages/QuestionDetailPage/api/scrapAPI.ts b/frontend/src/pages/QuestionDetailPage/api/scrapAPI.ts new file mode 100644 index 00000000..ac0edeb1 --- /dev/null +++ b/frontend/src/pages/QuestionDetailPage/api/scrapAPI.ts @@ -0,0 +1,13 @@ +import { api } from "@/api/config/axios.ts"; + +export const postScrapQuestionList = async (id: string) => { + const response = await api.post("/api/question-list/scrap", { + questionListId: id, + }); + return response.data.success; +}; + +export const deleteScrapQuestionList = async (id: string) => { + const response = await api.delete(`/api/question-list/scrap/${id}`); + return response.data.success; +}; diff --git a/frontend/src/pages/QuestionListPage.tsx b/frontend/src/pages/QuestionListPage.tsx deleted file mode 100644 index 1825b9c0..00000000 --- a/frontend/src/pages/QuestionListPage.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import Sidebar from "@components/common/Sidebar.tsx"; -import SearchBar from "@components/common/SearchBar.tsx"; -import QuestionsPreviewCard from "@components/questions/QuestionsPreviewCard.tsx"; -import Select from "@components/common/Select.tsx"; -import useToast from "@hooks/useToast.ts"; -import { useEffect, useState } from "react"; -import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; -import { IoMdAdd } from "react-icons/io"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import axios from "axios"; -import useAuth from "@hooks/useAuth.ts"; -import CreateButton from "@components/common/CreateButton.tsx"; -import { options } from "@/constants/CategoryData.ts"; - -interface QuestionList { - id: number; - title: string; - usage: number; - isStarred?: boolean; - questionCount: number; - categoryNames: string[]; -} - -const QuestionList = () => { - const toast = useToast(); - // 더미 데이터 - const [questionList, setQuestionList] = useState([]); - const [questionLoading, setQuestionLoading] = useState(true); - const navigate = useNavigate(); - - const { isLoggedIn } = useAuth(); - const [selectedCategory, setSelectedCategory] = useState("전체"); - const [searchParams, setSearchParams] = useSearchParams(); - - useEffect(() => { - getQuestionList(selectedCategory); - if (selectedCategory !== "전체") { - console.log("selectedCategory", selectedCategory); - setSearchParams({ category: selectedCategory }); - } - }, [selectedCategory]); - - useEffect(() => { - if (searchParams.get("category")) { - setSelectedCategory(searchParams.get("category") ?? "전체"); - } - }, [searchParams]); - - const getQuestionList = async (category?: string) => { - try { - const response = - category !== "전체" - ? await axios.post(`/api/question-list/category`, { - categoryName: category, - }) - : await axios.get("/api/question-list"); - const data = response.data.data.allQuestionLists ?? []; - setQuestionList(data); - setQuestionLoading(false); - } catch (error) { - console.error("질문지 리스트 불러오기 실패", error); - setQuestionList([]); - } - }; - - const handleNavigateDetail = (id: number) => { - navigate(`/questions/${id}`); - }; - - const handleNavigateCreate = () => { - if (isLoggedIn) { - navigate("/questions/create"); - } else { - toast.error("로그인이 필요한 기능입니다."); - } - }; - - return ( -
    - -
    -
    -

    - 질문지 목록 -

    -
    - - + +
    +
    + + + +
    +
    + ); +}; + +export default QuestionListPage; diff --git a/frontend/src/pages/QuestionListPage/hooks/useCategory.ts b/frontend/src/pages/QuestionListPage/hooks/useCategory.ts new file mode 100644 index 00000000..b9a4d9b5 --- /dev/null +++ b/frontend/src/pages/QuestionListPage/hooks/useCategory.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +const useCategory = () => { + const [selectedCategory, setSelectedCategory] = useState("전체"); + const [searchParams, setSearchParams] = useSearchParams(); + + useEffect(() => { + if (selectedCategory !== "전체") { + setSearchParams({ category: selectedCategory }); + } else { + setSearchParams({}); + } + }, [selectedCategory]); + + useEffect(() => { + if (searchParams.get("category")) { + setSelectedCategory(searchParams.get("category") ?? "전체"); + } + }, [searchParams]); + + return { + selectedCategory, + setSelectedCategory, + }; +}; + +export default useCategory; diff --git a/frontend/src/pages/QuestionListPage/types/QuestionList.d.ts b/frontend/src/pages/QuestionListPage/types/QuestionList.d.ts new file mode 100644 index 00000000..19578230 --- /dev/null +++ b/frontend/src/pages/QuestionListPage/types/QuestionList.d.ts @@ -0,0 +1,8 @@ +export interface QuestionList { + id: number; + title: string; + usage: number; + isStarred?: boolean; + questionCount: number; + categoryNames: string[]; +} diff --git a/frontend/src/pages/QuestionListPage/view/QuestionsPreviewList.tsx b/frontend/src/pages/QuestionListPage/view/QuestionsPreviewList.tsx new file mode 100644 index 00000000..67a0025b --- /dev/null +++ b/frontend/src/pages/QuestionListPage/view/QuestionsPreviewList.tsx @@ -0,0 +1,37 @@ +import QuestionsPreviewCard from "@components/questions/QuestionsPreviewCard.tsx"; +import type { QuestionList } from "@/pages/QuestionListPage/types/QuestionList.ts"; + +interface QuestionListProps { + questionList?: QuestionList[]; + questionLoading?: boolean; +} + +const QuestionsPreviewList = ({ + questionList, + questionLoading, +}: QuestionListProps) => { + return ( +
    + {questionList && + questionList.map((list) => ( + + ))} + + {!questionLoading && questionList?.length === 0 && ( +
    + 이런! 아직 질문지가 없습니다! 처음으로 생성해보시는 것은 어떤가요? ☃️ +
    + )} +
    + ); +}; + +export default QuestionsPreviewList; diff --git a/frontend/src/pages/SessionListPage.tsx b/frontend/src/pages/SessionListPage.tsx deleted file mode 100644 index 4c77139c..00000000 --- a/frontend/src/pages/SessionListPage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { IoMdAdd } from "react-icons/io"; -import SearchBar from "@/components/common/SearchBar.tsx"; -import Sidebar from "@components/common/Sidebar.tsx"; -import Select from "@components/common/Select.tsx"; -import SessionList from "@components/sessions/list/SessionList.tsx"; -import axios from "axios"; -import CreateButton from "@components/common/CreateButton.tsx"; -import { options } from "@/constants/CategoryData.ts"; - -interface Session { - id: number; - title: string; - category?: string; - inProgress: boolean; - host: { - nickname?: string; - socketId: string; - }; - participants: number; // 현재 참여자 - maxParticipants: number; - createdAt: number; -} - -const SessionListPage = () => { - const [sessionList, setSessionList] = useState([]); - const [inProgressList, setInProgressList] = useState([]); - const [listLoading, setListLoading] = useState(true); - const [inProgressListLoading, setInProgressListLoading] = useState(true); - const navigate = useNavigate(); - const [selectedCategory, setSelectedCategory] = useState("전체"); - - useEffect(() => { - getSessionList(); - }, [selectedCategory]); - - const getSessionList = async () => { - try { - const response = await axios.get("/api/rooms"); - if (Array.isArray(response.data)) { - const sessions = response.data ?? []; - setSessionList(sessions.filter((session) => !session.inProgress)); - setInProgressList(sessions.filter((session) => session.inProgress)); - setListLoading(false); - setInProgressListLoading(false); - } else { - throw new Error("세션리스트 불러오기 실패"); - } - } catch (e) { - console.error("세션리스트 불러오기 실패", e); - setSessionList([]); - setListLoading(false); - setInProgressListLoading(false); - } - }; - - return ( -
    - -
    -
    -

    스터디 세션 목록

    -
    - - + +
    +
    + + + + +
    + + ); +}; + +export default SessionListPage; diff --git a/frontend/src/pages/SessionListPage/api/useGetSessionList.ts b/frontend/src/pages/SessionListPage/api/useGetSessionList.ts new file mode 100644 index 00000000..c31636b3 --- /dev/null +++ b/frontend/src/pages/SessionListPage/api/useGetSessionList.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSessionList } from "@/api/session-list/getSessionList.ts"; + +export const useGetSessionList = () => { + const { data, isLoading, error } = useQuery({ + queryKey: ["sessionList"], + queryFn: () => getSessionList(), + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnMount: true, + refetchOnWindowFocus: true, + }); + + return { + data, + isLoading, + error, + }; +}; diff --git a/frontend/src/pages/SessionListPage/types/session.d.ts b/frontend/src/pages/SessionListPage/types/session.d.ts new file mode 100644 index 00000000..f5a2dcc4 --- /dev/null +++ b/frontend/src/pages/SessionListPage/types/session.d.ts @@ -0,0 +1,15 @@ +export interface Session { + id: string; + title: string; + category?: string[]; + inProgress: boolean; + host: { + nickname?: string; + socketId: string; + }; + questionListTitle?: string; + questionListId?: number; + participants: number; // 현재 참여자 + maxParticipants: number; + createdAt: number; +} diff --git a/frontend/src/pages/SessionListPage/view/SessionCard.tsx b/frontend/src/pages/SessionListPage/view/SessionCard.tsx new file mode 100644 index 00000000..e473f7d3 --- /dev/null +++ b/frontend/src/pages/SessionListPage/view/SessionCard.tsx @@ -0,0 +1,72 @@ +import { FaUserGroup } from "react-icons/fa6"; +import { IoArrowForwardSharp } from "react-icons/io5"; +import { Link } from "react-router-dom"; +import type { Session } from "@/pages/SessionListPage/types/session"; + +interface SessionCardProps extends Omit { + onEnter: () => void; + host: string; +} + +const SessionCard = ({ + category = ["기타"], + id, + title, + host, + participants, + maxParticipants, + inProgress, + questionListTitle, + onEnter, +}: SessionCardProps) => { + return ( +
  • +
    + + {category[0]} + + +

    {title}

    + +

    + {questionListTitle ?? "함께 면접 스터디에 참여해보세요!"} +

    +
    +
    + {host} • + + {" "} + 참여자 + {participants}/{maxParticipants}명 + +
    + {!inProgress && ( + + + + )} +
    +
    +
  • + ); +}; + +export default SessionCard; diff --git a/frontend/src/components/sessions/list/SessionList.tsx b/frontend/src/pages/SessionListPage/view/list/SessionList.tsx similarity index 54% rename from frontend/src/components/sessions/list/SessionList.tsx rename to frontend/src/pages/SessionListPage/view/list/SessionList.tsx index d13b9bfa..b0f2f0f4 100644 --- a/frontend/src/components/sessions/list/SessionList.tsx +++ b/frontend/src/pages/SessionListPage/view/list/SessionList.tsx @@ -1,21 +1,8 @@ import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; -import SessionCard from "@components/sessions/SessionCard.tsx"; +import SessionCard from "@/pages/SessionListPage/view/SessionCard.tsx"; import useToast from "@hooks/useToast.ts"; -import { useNavigate } from "react-router-dom"; - -interface Session { - id: number; - title: string; - category?: string; - inProgress: boolean; - host: { - nickname?: string; - socketId: string; - }; - participants: number; // 현재 참여자 - maxParticipants: number; - createdAt: number; -} +import type { Session } from "@/pages/SessionListPage/types/session"; +import NotFound from "@components/common/Animate/NotFound.tsx"; interface SessionListProps { listTitle: string; @@ -29,23 +16,22 @@ const SessionList = ({ sessionList, }: SessionListProps) => { const toast = useToast(); - const navigate = useNavigate(); - const renderSessionList = () => { return sessionList.map((session) => { return ( { toast.success("세션에 참가했습니다."); - navigate(`/session/${session.id}`); }} /> ); @@ -56,9 +42,18 @@ const SessionList = ({

    {listTitle}

    {listLoading && } -
      +
        {!listLoading && sessionList.length <= 0 ? ( -
      • 아직 아무도 세션을 열지 않았어요..!
      • +
      • + +
      • ) : ( renderSessionList() )} diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx deleted file mode 100644 index 89d395bf..00000000 --- a/frontend/src/pages/SessionPage.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import VideoContainer from "@components/session/VideoContainer.tsx"; -import { useParams } from "react-router-dom"; -import SessionSidebar from "@components/session/Sidebar/SessionSidebar.tsx"; -import SessionToolbar from "@components/session/Toolbar/SessionToolbar.tsx"; -import { useSession } from "@hooks/session/useSession"; -import useSocket from "@hooks/useSocket"; -import SessionHeader from "@components/session/SessionHeader"; -import { useEffect } from "react"; -import useToast from "@hooks/useToast.ts"; -import SidebarContainer from "@components/session/Sidebar/SidebarContainer.tsx"; - -const SessionPage = () => { - const { sessionId } = useParams(); - const toast = useToast(); - - useEffect(() => { - if (!sessionId) { - toast.error("유효하지 않은 세션 아이디입니다."); - } - }, [sessionId, toast]); - - const { socket } = useSocket(); - const { - nickname, - setNickname, - reaction, - peers, - userVideoDevices, - userAudioDevices, - isVideoOn, - isMicOn, - stream, - roomMetadata, - isHost, - participants, - handleMicToggle, - handleVideoToggle, - setSelectedAudioDeviceId, - setSelectedVideoDeviceId, - joinRoom, - emitReaction, - videoLoading, - peerMediaStatus, - requestChangeIndex, - startStudySession, - stopStudySession, - } = useSession(sessionId!); - - return ( -
        -
        - {/*{!username && (*/} - setNickname(e.target.value)} - className="border p-2 mr-2" - /> - {/*)}*/} - -
        - -
        -
        - -
        -
        - -
        -
        - {peers.map((peer) => ( - - ))} -
        -
        - -
        - - - -
        -
        - ); -}; -export default SessionPage; diff --git a/frontend/src/hooks/session/useMediaDevices.ts b/frontend/src/pages/SessionPage/hooks/useMediaDevices.ts similarity index 100% rename from frontend/src/hooks/session/useMediaDevices.ts rename to frontend/src/pages/SessionPage/hooks/useMediaDevices.ts diff --git a/frontend/src/hooks/session/useMediaStreamCleanup.ts b/frontend/src/pages/SessionPage/hooks/useMediaStreamCleanup.ts similarity index 100% rename from frontend/src/hooks/session/useMediaStreamCleanup.ts rename to frontend/src/pages/SessionPage/hooks/useMediaStreamCleanup.ts diff --git a/frontend/src/hooks/session/usePeerConnection.ts b/frontend/src/pages/SessionPage/hooks/usePeerConnection.ts similarity index 100% rename from frontend/src/hooks/session/usePeerConnection.ts rename to frontend/src/pages/SessionPage/hooks/usePeerConnection.ts diff --git a/frontend/src/hooks/session/usePeerConnectionCleanup.ts b/frontend/src/pages/SessionPage/hooks/usePeerConnectionCleanup.ts similarity index 100% rename from frontend/src/hooks/session/usePeerConnectionCleanup.ts rename to frontend/src/pages/SessionPage/hooks/usePeerConnectionCleanup.ts diff --git a/frontend/src/hooks/session/useReaction.ts b/frontend/src/pages/SessionPage/hooks/useReaction.ts similarity index 97% rename from frontend/src/hooks/session/useReaction.ts rename to frontend/src/pages/SessionPage/hooks/useReaction.ts index 0bc3f9ae..afe3b0ce 100644 --- a/frontend/src/hooks/session/useReaction.ts +++ b/frontend/src/pages/SessionPage/hooks/useReaction.ts @@ -7,7 +7,7 @@ import { useState, } from "react"; import { Socket } from "socket.io-client"; -import { PeerConnection } from "../type/session"; +import { PeerConnection } from "@/hooks/type/session"; import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent"; const REACTION_DURATION = 3000; diff --git a/frontend/src/hooks/session/useSession.ts b/frontend/src/pages/SessionPage/hooks/useSession.ts similarity index 90% rename from frontend/src/hooks/session/useSession.ts rename to frontend/src/pages/SessionPage/hooks/useSession.ts index d037cba1..b7b3b22b 100644 --- a/frontend/src/hooks/session/useSession.ts +++ b/frontend/src/pages/SessionPage/hooks/useSession.ts @@ -1,17 +1,17 @@ import { useEffect, useMemo, useState } from "react"; import useToast from "@hooks/useToast"; -import useMediaDevices from "@hooks/session/useMediaDevices"; -import usePeerConnection from "@hooks/session/usePeerConnection"; +import useMediaDevices from "./useMediaDevices"; +import usePeerConnection from "./usePeerConnection"; import useSocket from "@hooks/useSocket"; import { Participant, RoomMetadata } from "@hooks/type/session"; -import { useMediaStreamCleanup } from "@hooks/session/useMediaStreamCleanup"; -import { usePeerConnectionCleanup } from "@hooks/session/usePeerConnectionCleanup"; -import { useReaction } from "@hooks/session/useReaction"; +import { useMediaStreamCleanup } from "./useMediaStreamCleanup"; +import { usePeerConnectionCleanup } from "./usePeerConnectionCleanup"; +import { useReaction } from "./useReaction"; import { useSocketEvents } from "./useSocketEvents"; import { Socket } from "socket.io-client"; import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent"; import useAuth from "@hooks/useAuth"; -import useStudy from "@hooks/session/useStudy"; +import useStudy from "./useStudy"; export const useSession = (sessionId: string) => { const { socket } = useSocket(); diff --git a/frontend/src/hooks/session/useSocketEvents.ts b/frontend/src/pages/SessionPage/hooks/useSocketEvents.ts similarity index 100% rename from frontend/src/hooks/session/useSocketEvents.ts rename to frontend/src/pages/SessionPage/hooks/useSocketEvents.ts diff --git a/frontend/src/hooks/session/useStudy.ts b/frontend/src/pages/SessionPage/hooks/useStudy.ts similarity index 99% rename from frontend/src/hooks/session/useStudy.ts rename to frontend/src/pages/SessionPage/hooks/useStudy.ts index d305f8ff..5f25a461 100644 --- a/frontend/src/hooks/session/useStudy.ts +++ b/frontend/src/pages/SessionPage/hooks/useStudy.ts @@ -1,4 +1,4 @@ -import { STUDY_EMIT_EVENT } from "@/constants/WebSocket/StudyEvent.ts"; +import { STUDY_EMIT_EVENT } from "@/constants/WebSocket/StudyEvent"; import { Socket } from "socket.io-client"; import { RoomMetadata } from "@hooks/type/session"; diff --git a/frontend/src/pages/SessionPage/index.tsx b/frontend/src/pages/SessionPage/index.tsx new file mode 100644 index 00000000..b29f1a1d --- /dev/null +++ b/frontend/src/pages/SessionPage/index.tsx @@ -0,0 +1,122 @@ +import { useParams } from "react-router-dom"; +import SessionSidebar from "@/pages/SessionPage/view/SessionSidebar"; +import SessionToolbar from "@/pages/SessionPage/view/SessionToolbar"; +import useSocket from "@hooks/useSocket"; +import SessionHeader from "@/pages/SessionPage/view/SessionHeader"; +import useToast from "@hooks/useToast"; +import SidebarContainer from "@/pages/SessionPage/view/SidebarContainer"; +import VideoLayout from "./view/VideoLayout"; +import { useSession } from "./hooks/useSession"; + +const SessionPage = () => { + const { sessionId } = useParams(); + const toast = useToast(); + + if (!sessionId) { + toast.error("유효하지 않은 세션 아이디입니다."); + return null; + } + + const { socket } = useSocket(); + const { + nickname, + setNickname, + reaction, + peers, + userVideoDevices, + userAudioDevices, + isVideoOn, + isMicOn, + stream, + roomMetadata, + isHost, + participants, + handleMicToggle, + handleVideoToggle, + setSelectedAudioDeviceId, + setSelectedVideoDeviceId, + joinRoom, + emitReaction, + videoLoading, + peerMediaStatus, + requestChangeIndex, + startStudySession, + stopStudySession, + } = useSession(sessionId!); + + return ( +
        + {roomMetadata ? null : ( +
        + setNickname(e.target.value)} + className="border p-2 mr-2" + /> + +
        + )} + +
        +
        + + + +
        + + + +
        +
        + ); +}; +export default SessionPage; diff --git a/frontend/src/pages/SessionPage/view/SessionHeader.tsx b/frontend/src/pages/SessionPage/view/SessionHeader.tsx new file mode 100644 index 00000000..536b4d2c --- /dev/null +++ b/frontend/src/pages/SessionPage/view/SessionHeader.tsx @@ -0,0 +1,65 @@ +import { RoomMetadata } from "@hooks/type/session"; +import { useEffect, useState } from "react"; + +interface SessionHeaderProps { + roomMetadata: RoomMetadata | null; + participantsCount: number; +} + +const SECOND = 1000; + +const SessionHeader = ({ + //participantsCount, + roomMetadata, +}: SessionHeaderProps) => { + const [uptime, setUptime] = useState(0); + + useEffect(() => { + if (!roomMetadata?.inProgress) return; + const interval = setInterval(() => { + setUptime((prev) => prev + 1); + }, SECOND); + + return () => { + clearInterval(interval); + }; + }, [roomMetadata?.inProgress]); + + return ( +
        + {roomMetadata?.title ? ( +
        +

        + {roomMetadata?.title} +

        + + {/*roomMetadata && + `(${participantsCount} / ${roomMetadata.maxParticipants})` /* TODO: 참가자 수는 사이드바의 참가자 목록 옆에 표시하는게 좋을 듯함 */} + + {roomMetadata.inProgress ? ( +
        +
        + + 스터디 진행 중 +
        + + {Math.floor(uptime / 60)}분 {uptime % 60}초 + +
        + ) : ( +
        + + 스터디 시작 전 +
        + )} +
        + ) : ( +

        아직 세션에 참가하지 않았습니다.

        + )} +
        + ); +}; + +export default SessionHeader; diff --git a/frontend/src/components/session/Sidebar/SessionSidebar.tsx b/frontend/src/pages/SessionPage/view/SessionSidebar.tsx similarity index 50% rename from frontend/src/components/session/Sidebar/SessionSidebar.tsx rename to frontend/src/pages/SessionPage/view/SessionSidebar.tsx index f000d512..6d58689e 100644 --- a/frontend/src/components/session/Sidebar/SessionSidebar.tsx +++ b/frontend/src/pages/SessionPage/view/SessionSidebar.tsx @@ -1,13 +1,8 @@ import { FaClipboardList, FaFolder } from "react-icons/fa"; import { FaUserGroup } from "react-icons/fa6"; -import Modal from "../../common/Modal"; -import { useNavigate } from "react-router-dom"; import { Socket } from "socket.io-client"; -import useToast from "@hooks/useToast.ts"; import { TbCrown } from "react-icons/tb"; -import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent.ts"; import { Question } from "@hooks/type/session"; -import useModal from "@/hooks/useModal"; interface ParticipantsData { nickname: string; @@ -19,64 +14,24 @@ interface Props { questionList: Question[]; currentIndex: number; participants: ParticipantsData[]; - roomId: string | undefined; // TODO: sessionId가 입력되지 않았을 때(undefined) 처리 필요 + roomId: string; isHost: boolean; } const SessionSidebar = ({ - socket, questionList, currentIndex, participants, - roomId, - isHost, }: Props) => { - const navigate = useNavigate(); - const toast = useToast(); - const modal = useModal(); - - const existHandler = () => { - socket?.emit(SESSION_EMIT_EVENT.LEAVE, { roomId }); - toast.success("메인 화면으로 이동합니다."); - navigate("/sessions"); - }; - - const destroyAndExitHandler = () => { - socket?.off(SESSION_EMIT_EVENT.FINISH); - socket?.emit(SESSION_EMIT_EVENT.FINISH, { roomId }); - toast.success("메인 화면으로 이동합니다."); - navigate("/sessions"); - }; - - const HostModalData = { - title: "세션을 종료할까요?", - subtitle: "세션을 종료하면 참가자들이 모두 나가게 됩니다.", - leftButton: "방장 양도 후 종료", - rightButton: "세션 종료", - type: "red", - onLeftClick: existHandler, - onRightClick: destroyAndExitHandler, - }; - - const ParticipantModalData = { - title: "지금 나가면 다시 들어올 수 없어요!", - subtitle: "정말 종료하시겠어요?", - leftButton: "취소하기", - rightButton: "종료하기", - type: "red", - onLeftClick: () => { }, - onRightClick: existHandler, - }; - return (
        -
        -
        +
        +

        현재 질문 @@ -98,7 +53,7 @@ const SessionSidebar = ({ )}

        -
        +

        참가자 @@ -115,7 +70,7 @@ const SessionSidebar = ({ ))}

    -
    +

    이전 질문 @@ -137,38 +92,7 @@ const SessionSidebar = ({

    -
    - -
    - -
    ); }; diff --git a/frontend/src/components/session/Toolbar/SessionToolbar.tsx b/frontend/src/pages/SessionPage/view/SessionToolbar.tsx similarity index 90% rename from frontend/src/components/session/Toolbar/SessionToolbar.tsx rename to frontend/src/pages/SessionPage/view/SessionToolbar.tsx index f7c0ab48..3c3399f8 100644 --- a/frontend/src/components/session/Toolbar/SessionToolbar.tsx +++ b/frontend/src/pages/SessionPage/view/SessionToolbar.tsx @@ -1,5 +1,5 @@ -import HostOnlyTools from "@components/session/Toolbar/HostOnlyTools.tsx"; -import CommonTools from "@components/session/Toolbar/CommonTools.tsx"; +import HostOnlyTools from "@/components/session/HostOnlyTools"; +import CommonTools from "@/components/session/CommonTools"; interface Props { requestChangeIndex: ( @@ -17,12 +17,14 @@ interface Props { isMicOn: boolean; videoLoading: boolean; isHost: boolean; + roomId: string; isInProgress: boolean; startStudySession: () => void; stopStudySession: () => void; currentIndex: number; maxQuestionLength: number; } + const SessionToolbar = ({ requestChangeIndex, handleVideoToggle, @@ -36,6 +38,7 @@ const SessionToolbar = ({ isMicOn, videoLoading, isHost, + roomId, isInProgress, startStudySession, stopStudySession, @@ -59,6 +62,8 @@ const SessionToolbar = ({ isVideoOn={isVideoOn} isMicOn={isMicOn} videoLoading={videoLoading} + isHost={isHost} + roomId={roomId} /> {
    {children}
    ); diff --git a/frontend/src/pages/SessionPage/view/VideoLayout.tsx b/frontend/src/pages/SessionPage/view/VideoLayout.tsx new file mode 100644 index 00000000..70b1dd9e --- /dev/null +++ b/frontend/src/pages/SessionPage/view/VideoLayout.tsx @@ -0,0 +1,64 @@ +import VideoContainer from "@/components/session/VideoContainer"; + +interface VideoLayoutProps { + peers: any[]; + nickname: string; + isMicOn: boolean; + isVideoOn: boolean; + stream: MediaStream | null; + reaction: string; + videoLoading: boolean; + peerMediaStatus: Record; +} + +const VideoLayout = ({ + peers, + nickname, + isMicOn, + isVideoOn, + stream, + reaction, + videoLoading, + peerMediaStatus, +}: VideoLayoutProps) => { + const videoCount = 1 + peers.length; + + return ( +
    +
    + + {peers.map((peer) => ( + + ))} +
    +
    + ); +}; + +export default VideoLayout; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 772fcbd6..ddd5c32f 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,14 +1,15 @@ import App from "./App.tsx"; import CreateQuestionPage from "./pages/CreateQuestionPage.tsx"; import CreateSessionPage from "./pages/CreateSessionPage.tsx"; -import QuestionDetailPage from "./pages/QuestionDetailPage.tsx"; -import SessionListPage from "./pages/SessionListPage.tsx"; -import SessionPage from "./pages/SessionPage"; +import QuestionDetailPage from "./pages/QuestionDetailPage/QuestionDetailPage.tsx"; +import SessionListPage from "./pages/SessionListPage/SessionListPage.tsx"; +import SessionPage from "./pages/SessionPage/index.tsx"; import ErrorPage from "@/pages/ErrorPage.tsx"; import LoginPage from "@/pages/Login/LoginPage.tsx"; -import QuestionListPage from "@/pages/QuestionListPage.tsx"; +import QuestionListPage from "@/pages/QuestionListPage/QuestionListPage.tsx"; import AuthCallbackPage from "@/pages/Login/AuthCallbackPage.tsx"; import MyPage from "@/pages/MyPage/index.tsx"; +import ProtectedRouteLayout from "@components/layout/ProtectedRouteLayout.tsx"; export const routes = [ { @@ -49,7 +50,11 @@ export const routes = [ path: "/sessions/create", }, { - element: , + element: ( + + + + ), path: "/questions/create", }, { diff --git a/frontend/src/stores/useModalStore.ts b/frontend/src/stores/useModalStore.ts deleted file mode 100644 index 2a45500c..00000000 --- a/frontend/src/stores/useModalStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from "zustand"; - -interface ModalState { - isModalOpen: boolean; - openModal: () => void; - closeModal: () => void; -} - -const useModalStore = create((set) => ({ - isModalOpen: false, - - openModal: () => set({ isModalOpen: true }), - closeModal: () => set({ isModalOpen: false }), -})); - -export default useModalStore; diff --git a/frontend/src/stores/useUserStore.ts b/frontend/src/stores/useUserStore.ts new file mode 100644 index 00000000..2f327ad6 --- /dev/null +++ b/frontend/src/stores/useUserStore.ts @@ -0,0 +1,75 @@ +import { create } from "zustand"; +import { getMyInfo } from "@/api/user/getMyInfo"; +import { editMyInfo } from "@/api/user/editMyInfo"; + +interface EditData { + nickname?: string; + avatarUrl?: string; + password?: { + original: string; + newPassword: string; + }; +} + +interface UserData { + userId: string; + loginType: "github" | "native"; + nickname: string; + avatarUrl: string; +} + +interface UserState { + user: UserData | null; + isLoading: boolean; + error: Error | null; + + initStore: () => Promise; + getMyInfo: () => Promise; + editMyInfo: (userData: Partial) => Promise; +} + +export const useUserStore = create((set, get) => ({ + user: null, + isLoading: false, + error: null, + + initStore: async () => { + await get().getMyInfo(); + }, + + getMyInfo: async () => { + set({ isLoading: true }); + try { + const userData = await getMyInfo(); + set({ user: userData, error: null }); + } catch (err) { + set({ error: err as Error }); + } finally { + set({ isLoading: false }); + } + }, + + editMyInfo: async (userData) => { + set({ isLoading: true }); + try { + const updatedUser = await editMyInfo(userData); + const currentUser = get().user; + + set({ + user: currentUser + ? { + ...currentUser, + nickname: updatedUser.nickname, + avatarUrl: updatedUser.avartarUrl, + } + : null, + error: null, + }); + } catch (err) { + set({ error: err as Error }); + throw err; + } finally { + set({ isLoading: false }); + } + }, +})); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 28d33d35..96dfcd0e 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -3,33 +3,42 @@ export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], darkMode: "class", theme: { + screens: { + 'xs': '32.5rem', + 'sm': '40rem', + 'md': '48rem', + 'lg': '64rem', + 'xl': '80rem', + '2xl': '96rem', + '3xl': '120rem', + }, extend: { colors: { gray: { - white: "#FFFFFF", - 50: "#FAFAFA", - 100: "#EDEDED", - 200: "#E1E1E1", - 300: "#D9D9D9", - 400: "#6C6C6C", - 500: "#5E5E5E", - 600: "#3F3F3F", - black: "#171717", + white: "var(--color-gray-white)", + 50: "var(--color-gray-50)", + 100: "var(--color-gray-100)", + 200: "var(--color-gray-200)", + 300: "var(--color-gray-300)", + 400: "var(--color-gray-400)", + 500: "var(--color-gray-500)", + 600: "var(--color-gray-600)", + black: "var(--color-gray-black)", }, green: { - 50: "#F1FBF7", - 100: "#01BF6F", - 200: "#01AC64", - 300: "#019959", - 400: "#018F53", - 500: "#017343", - 600: "#005632", - 700: "#004327", + 50: "var(--color-green-50)", + 100: "var(--color-green-100)", + 200: "var(--color-green-200)", + 300: "var(--color-green-300)", + 400: "var(--color-green-400)", + 500: "var(--color-green-500)", + 600: "var(--color-green-600)", + 700: "var(--color-green-700)", }, point: { - 1: "#F04040", - 2: "#DFDDD5", - 3: "#2572E6", + 1: "var(--color-point-1)", + 2: "var(--color-point-2)", + 3: "var(--color-point-3)", }, }, borderWidth: { @@ -78,8 +87,10 @@ export default { 27.5: "27.5rem", 42.5: "42.5rem", 47.5: "47.5rem", - }, + }, }, }, - plugins: [], + plugins: [ + require('@tailwindcss/aspect-ratio') + ], }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea35a8e..508ec410 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: dependencies: '@moozeh/nestjs-redis-om': specifier: ^0.1.4 - version: 0.1.4(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(redis-om@0.4.7)(reflect-metadata@0.2.2) + version: 0.1.4(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(redis-om@0.4.7)(reflect-metadata@0.2.2) '@nestjs/common': specifier: ^10.0.0 version: 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -43,7 +43,7 @@ importers: version: 10.4.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.7)(rxjs@7.8.1) '@nestjs/typeorm': specifier: ^10.0.2 - version: 10.0.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))) + version: 10.0.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))) '@nestjs/websockets': specifier: ^10.4.6 version: 10.4.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -76,10 +76,10 @@ importers: version: 3.11.4 nestjs-paginate: specifier: ^10.0.0 - version: 10.0.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.21.1)(fastify@4.28.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))) + version: 10.0.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.21.1)(fastify@4.28.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))) nestjs-redis-om: specifier: ^0.1.2 - version: 0.1.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(redis-om@0.4.7)(reflect-metadata@0.2.2) + version: 0.1.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(redis-om@0.4.7)(reflect-metadata@0.2.2) passport: specifier: ^0.7.0 version: 0.7.0 @@ -122,7 +122,7 @@ importers: version: 10.2.3(chokidar@3.6.0)(typescript@5.6.3) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6) + version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)) '@types/cookie-parser': specifier: ^1.4.7 version: 1.4.7 @@ -232,6 +232,9 @@ importers: '@eslint/js': specifier: ^9.13.0 version: 9.13.0 + '@tailwindcss/aspect-ratio': + specifier: ^0.4.2 + version: 0.4.2(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -1399,6 +1402,11 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@tailwindcss/aspect-ratio@0.4.2': + resolution: {integrity: sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==} + peerDependencies: + tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' + '@tanstack/query-core@5.60.6': resolution: {integrity: sha512-tI+k0KyCo1EBJ54vxK1kY24LWj673ujTydCZmzEZKAew4NqZzTaVQJEuaG1qKj2M03kUHN46rchLRd+TxVq/zQ==} @@ -5869,7 +5877,7 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} - '@moozeh/nestjs-redis-om@0.1.4(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(redis-om@0.4.7)(reflect-metadata@0.2.2)': + '@moozeh/nestjs-redis-om@0.1.4(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(redis-om@0.4.7)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -5996,7 +6004,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + '@nestjs/swagger@8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -6011,7 +6019,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6)': + '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6))': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -6019,7 +6027,7 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6) - '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3)))': + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3)))': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -6190,6 +6198,10 @@ snapshots: '@sqltools/formatter@1.2.5': {} + '@tailwindcss/aspect-ratio@0.4.2(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3)))': + dependencies: + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3)) + '@tanstack/query-core@5.60.6': {} '@tanstack/query-devtools@5.61.3': {} @@ -8896,16 +8908,16 @@ snapshots: neo-async@2.6.2: {} - nestjs-paginate@10.0.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.21.1)(fastify@4.28.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))): + nestjs-paginate@10.0.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.21.1)(fastify@4.28.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3))): dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/swagger': 8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@nestjs/swagger': 8.0.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) express: 4.21.1 fastify: 4.28.1 lodash: 4.17.21 typeorm: 0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.4)(typescript@5.6.3)) - nestjs-redis-om@0.1.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(redis-om@0.4.7)(reflect-metadata@0.2.2): + nestjs-redis-om@0.1.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1))(redis-om@0.4.7)(reflect-metadata@0.2.2): dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)