diff --git a/.github/workflows/backend-deploy.yml b/.github/workflows/backend-deploy.yml new file mode 100644 index 0000000..da95be6 --- /dev/null +++ b/.github/workflows/backend-deploy.yml @@ -0,0 +1,78 @@ +name: "backend-docker-build" + +on: + push: + branches: [ "dev-be" ] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + # 노드 버전 설정 및 의존성 설치 + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18.17.1' + + - name: Corepack Enable + run: corepack enable + + - name: Install Dependencies + run: yarn install + + # 테스트 실행 (필요한 경우) + # - name: Run Tests + # run: yarn test + + docker: + name: Deploy Docker Image + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and Push + uses: docker/build-push-action@v3 + with: + context: . + file: ./packages/backend/Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-backend:0.1 + + deploy: + name: Deploy Backend + runs-on: ubuntu-latest + needs: docker + steps: + - name: SSH and Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.BACKEND_SSH_HOST }} + username: ${{ secrets.BACKEND_SSH_USERNAME }} + password: ${{ secrets.BACKEND_SSH_PASSWORD }} + port: ${{ secrets.BACKEND_SSH_PORT }} + script: | + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-backend:0.1 + docker rm -f backend || true + docker run -d --name backend -p 8080:8080 \ + -v /${{ secrets.CONTAINER_SSH_USERNAME }}/backend-logs:/app/packages/backend/logs/ \ + -e CONTAINER_SSH_HOST=${{ secrets.CONTAINER_SSH_HOST }} \ + -e CONTAINER_SSH_PORT=${{ secrets.CONTAINER_SSH_PORT }} \ + -e CONTAINER_SSH_USERNAME=${{ secrets.CONTAINER_SSH_USERNAME }} \ + -e CONTAINER_SSH_PASSWORD=${{ secrets.CONTAINER_SSH_PASSWORD }} \ + -e MONGODB_HOST=${{ secrets.MONGODB_HOST }} \ + ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-backend:0.1 diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml new file mode 100644 index 0000000..061a443 --- /dev/null +++ b/.github/workflows/frontend-deploy.yml @@ -0,0 +1,73 @@ +name: "frontend-docker-build" + +on: + push: + branches: [ "dev-fe" ] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + # 노드 버전 설정 및 의존성 설치 + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18.17.1' + + - name: Corepack Enable + run: corepack enable + + - name: Install Dependencies + run: yarn install + + - name: Build + run: | + cd packages/frontend + yarn build + + docker: + name: Deploy Docker Image + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and Push + uses: docker/build-push-action@v3 + with: + context: . + file: ./packages/frontend/Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 + + deploy: + name: Deploy Frontend + runs-on: ubuntu-latest + needs: docker + steps: + - name: SSH and Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.BACKEND_SSH_HOST }} + username: ${{ secrets.BACKEND_SSH_USERNAME }} + password: ${{ secrets.BACKEND_SSH_PASSWORD }} + port: ${{ secrets.BACKEND_SSH_PORT }} + script: | + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 + docker rm -f frontend || true + docker run -d --name frontend -p 3000:3000 \ + ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 diff --git a/.gitignore b/.gitignore index b534789..d9e29ee 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ !.yarn/sdks !.yarn/versions +.eslintcache + +.env diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..841f2f6 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +# 현재 브랜치의 이름을 가져오기 +current_branch=$(git rev-parse --abbrev-ref HEAD) + +if echo "$current_branch" | grep -q "fe"; then + . "$(dirname -- "$0")/_/husky.sh" + yarn workspace frontend run lint-staged +fi + +if echo "$current_branch" | grep -q "be"; then + . "$(dirname -- "$0")/_/husky.sh" + yarn workspace backend run lint-staged +fi \ No newline at end of file diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz deleted file mode 100644 index 4a03ad5..0000000 Binary files a/.yarn/install-state.gz and /dev/null differ diff --git a/.yarn/sdks/eslint/bin/eslint.js b/.yarn/sdks/eslint/bin/eslint.js new file mode 100755 index 0000000..9ef98e4 --- /dev/null +++ b/.yarn/sdks/eslint/bin/eslint.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire} = require(`module`); +const {resolve} = require(`path`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absRequire = createRequire(absPnpApiPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require eslint/bin/eslint.js + require(absPnpApiPath).setup(); + } +} + +// Defer to the real eslint/bin/eslint.js your application uses +module.exports = absRequire(`eslint/bin/eslint.js`); diff --git a/.yarn/sdks/eslint/lib/api.js b/.yarn/sdks/eslint/lib/api.js new file mode 100644 index 0000000..653b22b --- /dev/null +++ b/.yarn/sdks/eslint/lib/api.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire} = require(`module`); +const {resolve} = require(`path`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absRequire = createRequire(absPnpApiPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require eslint + require(absPnpApiPath).setup(); + } +} + +// Defer to the real eslint your application uses +module.exports = absRequire(`eslint`); diff --git a/.yarn/sdks/eslint/lib/unsupported-api.js b/.yarn/sdks/eslint/lib/unsupported-api.js new file mode 100644 index 0000000..30fdf15 --- /dev/null +++ b/.yarn/sdks/eslint/lib/unsupported-api.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire} = require(`module`); +const {resolve} = require(`path`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absRequire = createRequire(absPnpApiPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require eslint/use-at-your-own-risk + require(absPnpApiPath).setup(); + } +} + +// Defer to the real eslint/use-at-your-own-risk your application uses +module.exports = absRequire(`eslint/use-at-your-own-risk`); diff --git a/.yarn/sdks/eslint/package.json b/.yarn/sdks/eslint/package.json new file mode 100644 index 0000000..d65ecf3 --- /dev/null +++ b/.yarn/sdks/eslint/package.json @@ -0,0 +1,14 @@ +{ + "name": "eslint", + "version": "8.53.0-sdk", + "main": "./lib/api.js", + "type": "commonjs", + "bin": { + "eslint": "./bin/eslint.js" + }, + "exports": { + "./package.json": "./package.json", + ".": "./lib/api.js", + "./use-at-your-own-risk": "./lib/unsupported-api.js" + } +} diff --git a/package.json b/package.json index 6110587..1a7da9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "packageManager": "yarn@4.0.2", "devDependencies": { + "eslint": "^8.53.0", "husky": "^8.0.0", "pinst": "^3.0.0", "prettier": "^3.1.0", diff --git a/packages/backend/.dockerignore b/packages/backend/.dockerignore new file mode 100644 index 0000000..f1ca1af --- /dev/null +++ b/packages/backend/.dockerignore @@ -0,0 +1,26 @@ +# 버전 관리 시스템 +.git +.gitignore + +# 노드 모듈 +node_modules + +# 로그 파일 +npm-debug.log +yarn-error.log + +# 빌드 디렉토리 +dist +build + +# 개발 도구 설정 +.editorconfig +*.env +*.env.local +*.env.development.local +*.env.test.local +*.env.production.local + +# OS 관련 파일 +.DS_Store +Thumbs.db diff --git a/packages/backend/.eslintrc.js b/packages/backend/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/packages/backend/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/packages/backend/.gitignore b/packages/backend/.gitignore new file mode 100644 index 0000000..49c2f3a --- /dev/null +++ b/packages/backend/.gitignore @@ -0,0 +1,39 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +.env + +*.sqlite \ No newline at end of file diff --git a/packages/backend/.prettierrc b/packages/backend/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/packages/backend/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile new file mode 100644 index 0000000..215b59f --- /dev/null +++ b/packages/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18.17.1 + +WORKDIR /app + +COPY . . + +RUN corepack enable +RUN yarn install + +EXPOSE 8080 + +CMD ["sh", "-c", "cd packages/backend && yarn run start"] diff --git a/packages/backend/README.md b/packages/backend/README.md new file mode 100644 index 0000000..8372941 --- /dev/null +++ b/packages/backend/README.md @@ -0,0 +1,73 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ yarn install +``` + +## Running the app + +```bash +# development +$ yarn run start + +# watch mode +$ yarn run start:dev + +# production mode +$ yarn run start:prod +``` + +## Test + +```bash +# unit tests +$ yarn run test + +# e2e tests +$ yarn run test:e2e + +# test coverage +$ yarn run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/packages/backend/git-challenge-quiz.csv b/packages/backend/git-challenge-quiz.csv new file mode 100644 index 0000000..e5c63b2 --- /dev/null +++ b/packages/backend/git-challenge-quiz.csv @@ -0,0 +1,73 @@ +id,title,description,category,keyword +1,git init,현재 디렉터리를 새로운 Git 저장소로 만들어주세요.,Git Start,init +2,git config,현재 디렉터리의 Git 저장소 환경에서 user name과 user email을 여러분의 name과 email로 설정해주세요.,Git Start,config +3,git add & git status,현재 변경된 파일 중에서 `achitecture.md` 파일을 제외하고 staging 해주세요.,Git Start,"status, add" +4,git commit,현재 디렉터리 내의 모든 파일을 commit 해주세요.,Git Start,commit +5,git branch & git switch & git checkout,`dev`라는 이름의 branch를 생성해주세요.,Git Start,"branch, switch, checkout" +6,git switch,현재 상황을 commit하고 `main` branch로 돌아가주세요.,Git Start,switch +7,git commit --amend,"당신은 프로젝트에서 회원가입, 로그인, 회원탈퇴 기능을 담당하게 되었습니다. +먼저 회원가입 기능을 구현한 다음 테스트 코드를 작성하고 commit을 생성했습니다. +그런데 commit에 테스트 파일이 포함되지 않았으며, commit message가 ""회원가입 긴ㅇ 구현""으로 오타가 났습니다. +`signup.test.js` 테스트 파일을 추가하고 commit message를 ""회원가입 기능 구현""로 수정해주세요.",Git Advanced,"status, add, commit" +8,git reset HEAD~1,"당신은 회원가입 기능 구현과 테스트 코드를 작성해 ""회원가입 기능 구현"" commit을 생성했습니다. +그러나 ""회원가입 기능 구현""과 ""회원가입 테스트 코드 작성"" 두 개의 commit으로 나누려고 합니다. +""회원가입 기능 구현"" commit을 취소하고 `signup.js` 파일로 ""회원가입 기능 구현"" commit을, `signup.test.js` 파일로 ""회원가입 테스트 코드 작성"" commit을 순서대로 생성해주세요.",Git Advanced,"reset, add, commit" +9,git restore,"당신이 코드를 작성하던 중에 실수로 `important.js` 파일을 수정해 변경 사항이 생겼습니다. +해당 파일을 최근 `commit` 상태로 되돌려주세요.",Git Advanced,restore +10,git clean,당신은 추적되지 않는 임시 파일을 세 개 만들었습니다. 임시 파일을 모두 삭제해주세요.,Git Advanced,clean +11,git stash,"당신이 로그인 기능을 구현하던 중 급하게 버그 픽스 요청이 들어왔습니다. +새로운 branch를 생성해서 작업하려 했지만, 변경 사항이 있어 branch 이동이 불가능합니다. +현재 기능 구현이 완료되지 않아 commit하는 것이 껄끄럽기 때문에 commit 없이 변경 사항을 보관하고, ""A 기능 구현"" commit으로 돌아가 `hotfix/fixA` 브랜치를 생성해주세요.",Git Advanced,"stash, log, checkout, switch" +12,git cherry-pick,"당신은 버그를 해결한 후 ""버그 수정"" commit을 생성했습니다. +`hotfix/fixA` branch에서 작업을 해야 하는데 실수로 `feat/somethingB` branch에서 작업 했습니다. +`feat/somthingB` branch 에서 ""버그 수정"" commit을 `hotfix/fixA` branch로 가져오고, `hotfix/fixA` branch를 `main` branch로 merge해주세요. +그리고 `feat/somethingB` branch에서 ""버그 수정"" commit을 취소해주세요.",Git Advanced,"log, switch, cherry-pick, merge, reset" +13,git rebase,"당신은 로그인 기능과 회원 탈퇴 기능 구현을 마쳤습니다. +`main` branch로 merge하기 전에 commit log를 확인해보니 다음과 같이 commit message에 오타가 났습니다. + +""로그인 기느 ㄱ후ㅕㄴ"" + +잘못 입력한 commit message를 ""로그인 기능 구현""으로 수정해주세요.",Git Advanced,"log, rebase" +14,git revert,"회원가입, 로그인, 회원 탈퇴 기능 구현을 끝내고 `feat/somethingB` branch를 `main` branch로 merge했습니다. +그런데 실수로 ""사용자 프로필 기능 구현"" commit까지 `main` branch에 merge 되었습니다. +`main` branch는 다른 팀원들과 협업하는 branch라 기존 commit 기록이 변경되면 안 됩니다. +`git reset`이 아닌 다른 방법으로 ""사용자 프로필 기능 구현"" commit을 취소해주세요.",Git Advanced,"log, revert" +15,"git clone, upstream 등록","당신은 새로운 팀에 배정되었습니다. 이제부터 전임자의 일을 이어서 진행해야 합니다. + +배정된 팀의 저장소 전략은 다음과 같습니다. +1. 원본 저장소 `upstream`이 있다. +2. 우리 팀에서 `upstream`으로부터 fork한 저장소인 `origin`이 있다. +3. `origin`에서 branch를 생성하여 작업한다. +4. 당신은 `origin`으로 push할 수 있으며, 필요에 따라 `upstream`으로 PR을 생성할 수 있다. + +`origin` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-upstream.git +`upstream` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-origin.git + +위 정보에 맞추어 `origin` 저장소를 로컬 저장소로 가져오고, `upstream` 저장소를 등록해주세요. (각각의 저장소의 이름도 동일해야 합니다.) + +* 저장소를 가져올 때 현재 디렉터리로 가져와주세요.('.' 표현을 이용해야 합니다)",Remote Start,"clone, remote" +16,git switch -c,"당신은 ""somethingA""라는 기능을 구현하기 위한 branch를 생성하고자 합니다. 팀의 컨벤션에 따라 브랜치의 이름은 `feat/somethingA`입니다. +`feat/somethingA`라는 이름의 branch를 생성하고 해당 branch로 이동해주세요.",Remote Start,"switch, checkout" +17,git fetch & git pull,"당신이 개발한 기능을 포함한 모든 기능들이 `origin` 저장소의 `main` branch에 합쳐졌습니다. +그리고 당신은 `origin` 저장소의 `main` branch에서의 구현 결과를 다른 사람들에게 공유하고자 합니다. + +로컬 저장소에는 다음과 같은 설정이 있습니다. + +`origin` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-upstream.git +`upstream` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-origin.git + +현재 당신의 로컬 저장소의 코드는 `origin` 저장소의 버전에 비해 뒤쳐져 있습니다. 로컬 저장소의 코드를 최신화 해 주세요.",Remote Start,"pull, fetch, rebase" +18,git push,"당신은 새로운 팀에 배정되었고, 이제 막 첫 기능 개발을 완료했습니다. + +배정된 팀의 저장소 전략은 다음과 같습니다. +1. 원본 저장소 `upstream`이 있다. +2. 우리 팀에서 `upstream`으로부터 `fork`한 저장소인 `origin`이 있다. +3. `origin`에서 branch를 생성하여 작업한다. +4. 당신은 `origin`으로 push할 수 있으며, 필요에 따라 `upstream`으로 PR을 생성할 수 있다. + +당신은 `origin` 저장소를 복제한 뒤 `feat/merge-master`라는 branch에서 기능 개발을 완료했습니다. +당신은 `mergeMaster.js` 라는 파일을 새로 생성하였으며, 기존에 있던 `MergeMasters.md` 파일을 수정했습니다. +위 정보에 맞추어 `origin`저장소에 `feat/merge-master` branch의 정보를 최신화하고자 한다면 어떤 명령을 수행해야 할까요?",Remote Start,"push, pull" +19,git branch --merged + git branch -d,"당신은 개발을 완료한 뒤 push한 commit들을 기반으로 Github 상에서 PR을 생성했고, 해당 PR은 성공적으로 merge 되었습니다. +merge된 branch를 포함해서 현재 로컬 저장소에는 필요 없는 branch들이 있습니다. +이제는 필요 없어진 branch들을 확인하고 모두 삭제해 주세요.",Remote Start,"switch, branch" \ No newline at end of file diff --git a/packages/backend/nest-cli.json b/packages/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/packages/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..93563cd --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,94 @@ +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "prettier --write", + "eslint --cache --fix" + ] + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/mongoose": "^10.0.2", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.16", + "@nestjs/typeorm": "^10.0.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "cookie-parser": "^1.4.6", + "mongoose": "^8.0.1", + "nest-winston": "^1.9.4", + "papaparse": "^5.4.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "sqlite3": "^5.1.6", + "ssh2": "^1.14.0", + "typeorm": "^0.3.17", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/papaparse": "^5", + "@types/ssh2": "^1", + "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "lint-staged": "^15.1.0", + "prettier": "^3.1.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.0.0-beta" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/packages/backend/src/app.controller.spec.ts b/packages/backend/src/app.controller.spec.ts new file mode 100644 index 0000000..d22f389 --- /dev/null +++ b/packages/backend/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/packages/backend/src/app.controller.ts b/packages/backend/src/app.controller.ts new file mode 100644 index 0000000..e6e4501 --- /dev/null +++ b/packages/backend/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + // @Get() + // getHello(): string { + // return this.appService.getHello(); + // } +} diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts new file mode 100644 index 0000000..d6f0a39 --- /dev/null +++ b/packages/backend/src/app.module.ts @@ -0,0 +1,51 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { WinstonModule } from 'nest-winston'; +import * as winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; +import { format } from 'winston'; +import { typeOrmConfig } from './configs/typeorm.config'; +import { QuizzesModule } from './quizzes/quizzes.module'; +import { LoggingInterceptor } from './common/logging.interceptor'; + +@Module({ + imports: [ + TypeOrmModule.forRoot(typeOrmConfig), + QuizzesModule, + ConfigModule.forRoot({ + isGlobal: true, + }), + WinstonModule.forRoot({ + transports: [ + new winston.transports.Console(), + new DailyRotateFile({ + filename: 'logs/backend-application-%DATE%.log', + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + }), + ], + format: format.combine( + format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss', + }), + format.printf( + (info) => `${info.timestamp} ${info.level}: ${info.message}`, + ), + ), + }), + ], + controllers: [AppController], + providers: [ + AppService, + { + provide: 'APP_INTERCEPTOR', + useClass: LoggingInterceptor, + }, + ], +}) +export class AppModule {} diff --git a/packages/backend/src/app.service.ts b/packages/backend/src/app.service.ts new file mode 100644 index 0000000..927d7cc --- /dev/null +++ b/packages/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/packages/backend/src/common/logging.interceptor.ts b/packages/backend/src/common/logging.interceptor.ts new file mode 100644 index 0000000..0a76633 --- /dev/null +++ b/packages/backend/src/common/logging.interceptor.ts @@ -0,0 +1,28 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Inject, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Logger } from 'winston'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + constructor(@Inject('winston') private readonly logger: Logger) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const method = request.method; + const url = request.url; + const sessionId = request.cookies?.sessionId || "(it's new session)"; + + this.logger.log( + 'info', + `Request ${method} ${url} from session: ${sessionId}`, + ); + + return next.handle(); + } +} diff --git a/packages/backend/src/configs/typeorm.config.ts b/packages/backend/src/configs/typeorm.config.ts new file mode 100644 index 0000000..3eb0e28 --- /dev/null +++ b/packages/backend/src/configs/typeorm.config.ts @@ -0,0 +1,8 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; + +export const typeOrmConfig: TypeOrmModuleOptions = { + type: 'sqlite', + database: 'db.sqlite', + entities: [__dirname + '/../**/entity/*.entity.{js,ts}'], + synchronize: true, +}; diff --git a/packages/backend/src/containers/containers.module.ts b/packages/backend/src/containers/containers.module.ts new file mode 100644 index 0000000..ea228a1 --- /dev/null +++ b/packages/backend/src/containers/containers.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ContainersService } from './containers.service'; + +@Module({ + providers: [ContainersService], + exports: [ContainersService], +}) +export class ContainersModule {} diff --git a/packages/backend/src/containers/containers.service.spec.ts b/packages/backend/src/containers/containers.service.spec.ts new file mode 100644 index 0000000..0b9960f --- /dev/null +++ b/packages/backend/src/containers/containers.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ContainersService } from './containers.service'; + +describe('ContainersService', () => { + let service: ContainersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ContainersService], + }).compile(); + + service = module.get(ContainersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/backend/src/containers/containers.service.ts b/packages/backend/src/containers/containers.service.ts new file mode 100644 index 0000000..6c168aa --- /dev/null +++ b/packages/backend/src/containers/containers.service.ts @@ -0,0 +1,122 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from 'winston'; +import { CommandResponseDto } from 'src/quizzes/dto/command-response.dto'; +import { Client } from 'ssh2'; + +@Injectable() +export class ContainersService { + constructor( + private configService: ConfigService, + @Inject('winston') private readonly logger: Logger, + ) {} + + private async getSSH(): Promise { + const conn = new Client(); + + await new Promise((resolve, reject) => { + conn + .on('ready', () => resolve()) + .on('error', reject) + .connect({ + host: this.configService.get('CONTAINER_SSH_HOST'), + port: this.configService.get('CONTAINER_SSH_PORT'), + username: this.configService.get('CONTAINER_SSH_USERNAME'), + password: this.configService.get('CONTAINER_SSH_PASSWORD'), + }); + }); + + return conn; + } + + private async executeSSHCommand( + command: string, + ): Promise<{ stdoutData: string; stderrData: string }> { + const conn: Client = await this.getSSH(); + + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) { + reject(new Error('SSH command execution Server error')); + return; + } + let stdoutData = ''; + let stderrData = ''; + stream + .on('close', () => { + conn.end(); + resolve({ stdoutData, stderrData }); + }) + .on('data', (chunk) => { + stdoutData += chunk; + }); + + stream.stderr.on('data', (chunk) => { + stderrData += chunk; + }); + }); + }); + } + + async runGitCommand( + container: string, + command: string, + ): Promise { + const { stdoutData, stderrData } = await this.executeSSHCommand( + `docker exec -w ~/quiz/ ${container} ${command}`, + ); + + if (stderrData) { + return { message: stderrData, result: 'fail' }; + } + + return { message: stdoutData, result: 'success' }; + } + + async getContainer(quizId: number): Promise { + // 일단은 컨테이너를 생성해 준다. + // 차후에는 준비된 컨테이너 중 하나를 선택해서 준다. + // quizId에 대한 유효성 검사는 이미 끝났다(이미 여기서는 DB 접근 불가) + + const host: string = this.configService.get( + 'CONTAINER_SSH_USERNAME', + ); + + const createContainerCommand = `docker run --network none -itd mergemasters/alpine-git:0.1 /bin/sh`; + const { stdoutData } = await this.executeSSHCommand(createContainerCommand); + const containerId = stdoutData.trim(); + + const createDirectoryCommand = `docker exec ${containerId} mkdir -p /${host}/quiz/`; + await this.executeSSHCommand(createDirectoryCommand); + + const copyFilesCommand = `docker cp ~/quizzes/${quizId}/. ${containerId}:/${host}/quiz/`; + await this.executeSSHCommand(copyFilesCommand); + + return containerId; + } + + async isValidateContainerId(containerId: string): Promise { + const command = `docker ps -a --filter "id=${containerId}" --format "{{.ID}}"`; + + const { stdoutData, stderrData } = await this.executeSSHCommand(command); + + if (stderrData) { + // 도커 미설치 등의 에러일 듯 + throw new Error(stderrData); + } + + return stdoutData.trim() !== ''; + } + + async deleteContainer(containerId: string): Promise { + const command = `docker rm -f ${containerId}`; + + const { stdoutData, stderrData } = await this.executeSSHCommand(command); + + console.log(`container deleted : ${stdoutData}`); + + if (stderrData) { + throw new Error(stderrData); + } + } +} diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts new file mode 100644 index 0000000..213f901 --- /dev/null +++ b/packages/backend/src/main.ts @@ -0,0 +1,31 @@ +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import cookieParser from 'cookie-parser'; +import fs from 'fs'; + +async function bootstrap() { + const dbPath = 'db.sqlite'; + + // DB 파일이 존재하면 삭제 + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + } + + const app = await NestFactory.create(AppModule); + + app.use(cookieParser()); + + const config = new DocumentBuilder() + .setTitle("Merge Masters' Git Challenge API") + .setDescription('Git Challenge의 API 설명서입니다! 파이팅!') + .setVersion('1.0') + .addTag('quizzes') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + + await app.listen(8080); +} +bootstrap(); diff --git a/packages/backend/src/quizzes/dto/command-request.dto.ts b/packages/backend/src/quizzes/dto/command-request.dto.ts new file mode 100644 index 0000000..6efdc74 --- /dev/null +++ b/packages/backend/src/quizzes/dto/command-request.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CommandRequestDto { + @ApiProperty({ description: '실행할 명령문', example: 'git branch' }) + command: string; +} diff --git a/packages/backend/src/quizzes/dto/command-response.dto.ts b/packages/backend/src/quizzes/dto/command-response.dto.ts new file mode 100644 index 0000000..a8c35aa --- /dev/null +++ b/packages/backend/src/quizzes/dto/command-response.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CommandResponseDto { + @ApiProperty({ + description: '실행한 stdout/stderr 결과', + example: '* main\n', + }) + message: string; + + @ApiProperty({ + description: '실행 결과 요약(stdout => success, stderr => fail, vi)', + example: 'success', + }) + result: 'success' | 'fail' | 'vi'; + + @ApiProperty({ + description: 'git 그래프 상황(아직 미구현)', + example: '아직 미구현이에요', + }) + graph?: string; +} diff --git a/packages/backend/src/quizzes/dto/quiz.dto.ts b/packages/backend/src/quizzes/dto/quiz.dto.ts new file mode 100644 index 0000000..00eaec4 --- /dev/null +++ b/packages/backend/src/quizzes/dto/quiz.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsString, IsArray } from 'class-validator'; + +export class QuizDto { + @ApiProperty({ description: '문제 ID', example: 3 }) + @IsInt() + readonly id: number; + + @IsString() + @ApiProperty({ description: '문제 제목', example: 'git add & git status' }) + readonly title: string; + + @IsString() + @ApiProperty({ + description: '문제 내용', + example: + '현재 변경된 파일 중에서 `achitecture.md` 파일을 제외하고 staging 해주세요.', + }) + readonly description: string; + + @IsArray() + @IsString({ each: true }) + @ApiProperty({ description: '문제 핵심 키워드', example: ['add', 'status'] }) + readonly keywords: string[]; + + @IsString() + @ApiProperty({ description: '문제 카테고리', example: 'Git Start' }) + readonly category: string; +} diff --git a/packages/backend/src/quizzes/dto/quizzes.dto.ts b/packages/backend/src/quizzes/dto/quizzes.dto.ts new file mode 100644 index 0000000..66c603d --- /dev/null +++ b/packages/backend/src/quizzes/dto/quizzes.dto.ts @@ -0,0 +1,51 @@ +// problem.dto.ts +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsInt, IsString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QuizDto { + @IsInt() + id: number; + + @IsString() + title: string; +} + +export class CategoryQuizzesDto { + @IsInt() + id: number; + + @IsString() + category: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => QuizDto) + quizzes: QuizDto[]; +} + +export class QuizzesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CategoryQuizzesDto) + @ApiProperty({ + description: '문제 제목 리스트', + example: [ + { + id: 1, + category: 'Git Start', + quizzes: [ + { id: 1, title: 'git init' }, + { id: 2, title: 'git config' }, + { id: 3, title: 'git add & git status' }, + ], + }, + { + id: 2, + category: 'Git Advanced', + quizzes: [{ id: 4, title: 'git commit --amend' }], + }, + ], + }) + categories: CategoryQuizzesDto[]; +} diff --git a/packages/backend/src/quizzes/entity/category.entity.ts b/packages/backend/src/quizzes/entity/category.entity.ts new file mode 100644 index 0000000..a4abb84 --- /dev/null +++ b/packages/backend/src/quizzes/entity/category.entity.ts @@ -0,0 +1,14 @@ +import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; +import { Quiz } from './quiz.entity'; + +@Entity() +export class Category { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @OneToMany(() => Quiz, (quiz) => quiz.category) + quizzes: Quiz[]; +} diff --git a/packages/backend/src/quizzes/entity/keyword.entity.ts b/packages/backend/src/quizzes/entity/keyword.entity.ts new file mode 100644 index 0000000..d353a53 --- /dev/null +++ b/packages/backend/src/quizzes/entity/keyword.entity.ts @@ -0,0 +1,20 @@ +import { + BaseEntity, + Column, + Entity, + ManyToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Quiz } from './quiz.entity'; + +@Entity() +export class Keyword extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + keyword: string; + + @ManyToMany(() => Quiz, (quiz) => quiz.keywords) + quizzes: Quiz[]; +} diff --git a/packages/backend/src/quizzes/entity/quiz.entity.ts b/packages/backend/src/quizzes/entity/quiz.entity.ts new file mode 100644 index 0000000..24ceb96 --- /dev/null +++ b/packages/backend/src/quizzes/entity/quiz.entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + BaseEntity, + JoinTable, + ManyToMany, + ManyToOne, +} from 'typeorm'; +import { Category } from './category.entity'; +import { Keyword } from './keyword.entity'; + +@Entity() +export class Quiz extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column() + description: string; + + @ManyToOne(() => Category, (category) => category.quizzes) + category: Category; + + @ManyToMany(() => Keyword, (keyword) => keyword.quizzes) + @JoinTable() + keywords: Keyword[]; +} diff --git a/packages/backend/src/quizzes/quizzes.controller.spec.ts b/packages/backend/src/quizzes/quizzes.controller.spec.ts new file mode 100644 index 0000000..a0acc00 --- /dev/null +++ b/packages/backend/src/quizzes/quizzes.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { QuizzesController } from './quizzes.controller'; + +describe('QuizzesController', () => { + let controller: QuizzesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [QuizzesController], + }).compile(); + + controller = module.get(QuizzesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/backend/src/quizzes/quizzes.controller.ts b/packages/backend/src/quizzes/quizzes.controller.ts new file mode 100644 index 0000000..08aa6a9 --- /dev/null +++ b/packages/backend/src/quizzes/quizzes.controller.ts @@ -0,0 +1,191 @@ +import { + Controller, + Get, + Post, + Param, + Body, + HttpException, + HttpStatus, + Res, + Req, + Inject, + Delete, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBody, +} from '@nestjs/swagger'; +import { Logger } from 'winston'; +import { QuizDto } from './dto/quiz.dto'; +import { QuizzesService } from './quizzes.service'; +import { QuizzesDto } from './dto/quizzes.dto'; +import { CommandRequestDto } from './dto/command-request.dto'; +import { CommandResponseDto } from './dto/command-response.dto'; +import { SessionService } from '../session/session.service'; +import { Request, Response } from 'express'; +import { ContainersService } from '../containers/containers.service'; + +@ApiTags('quizzes') +@Controller('api/v1/quizzes') +export class QuizzesController { + constructor( + private readonly quizService: QuizzesService, + private readonly sessionService: SessionService, + private readonly containerService: ContainersService, + @Inject('winston') private readonly logger: Logger, + ) {} + + @Get(':id') + @ApiOperation({ summary: 'ID를 통해 문제 정보를 가져올 수 있습니다.' }) + @ApiResponse({ + status: 200, + description: 'Returns the quiz details', + type: QuizDto, + }) + @ApiParam({ name: 'id', description: '문제 ID' }) + async getProblemById(@Param('id') id: number): Promise { + const quizDto = await this.quizService.getQuizById(id); + + return quizDto; + } + + @Get('/') + @ApiOperation({ + summary: '카테고리 별로 모든 문제의 제목과 id를 가져올 수 있습니다.', + }) + @ApiResponse({ + status: 200, + description: '카테고리 별로 문제의 제목과 id가 리턴됩니다.', + type: QuizzesDto, + }) + async getProblemsGroupedByCategory(): Promise { + return this.quizService.findAllProblemsGroupedByCategory(); + } + + @Post(':id/command') + @ApiOperation({ summary: 'Git 명령을 실행합니다.' }) + @ApiResponse({ + status: 200, + description: 'Git 명령의 실행 결과(stdout/stderr)를 리턴합니다.', + type: CommandResponseDto, + }) + @ApiParam({ name: 'id', description: '문제 ID' }) + @ApiBody({ description: 'Command to be executed', type: CommandRequestDto }) + async runGitCommand( + @Param('id') id: number, + @Body() execCommandDto: CommandRequestDto, + @Res() response: Response, + @Req() request: Request, + ): Promise { + try { + let sessionId = request.cookies?.sessionId; + + if (!sessionId) { + // 세션 아이디가 없다면 + response.cookie( + 'sessionId', + (sessionId = await this.sessionService.createSession()), + { + httpOnly: true, + // 개발 이후 활성화 시켜야함 + // secure: true, + }, + ); // 세션 아이디를 생성한다. + } + + let containerId = await this.sessionService.getContainerIdBySessionId( + sessionId, + id, + ); + + if (!(await this.containerService.isValidateContainerId(containerId))) { + this.logger.log( + 'info', + 'no docker container or invalid container Id. creating container..', + ); + containerId = await this.containerService.getContainer(id); + await this.sessionService.setContainerBySessionId( + sessionId, + id, + containerId, + ); + } + + this.logger.log( + 'info', + `running command "${execCommandDto.command}" for container ${containerId}`, + ); + + const { message, result } = await this.containerService.runGitCommand( + containerId, + execCommandDto.command, + ); + + this.sessionService.pushLogBySessionId( + execCommandDto.command, + sessionId, + id, + ); + + response.status(HttpStatus.OK).send({ + message, + result, + // graph: 필요한 경우 여기에 추가 + }); + return; + } catch (error) { + throw new HttpException( + { + message: 'Internal Server Error', + result: 'fail', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Delete(':id/command') + @ApiOperation({ summary: 'Git 명령기록과, 할당된 컨테이너를 삭제합니다' }) + @ApiResponse({ + status: 200, + description: + 'session에 저장된 command 기록과 컨테이너를 삭제하고, 실제 컨테이너도 삭제 합니다', + }) + @ApiParam({ name: 'id', description: '문제 ID' }) + async deleteCommandHistory( + @Param('id') id: number, + @Req() request: Request, + ): Promise { + try { + const sessionId = request.cookies?.sessionId; + + if (!sessionId) { + return; + } + + const containerId = await this.sessionService.getContainerIdBySessionId( + sessionId, + id, + ); + + if (!containerId) { + return; + } + + this.containerService.deleteContainer(containerId); + + this.sessionService.deleteCommandHistory(sessionId, id); + } catch (e) { + throw new HttpException( + { + message: 'Internal Server Error', + result: 'fail', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/packages/backend/src/quizzes/quizzes.module.ts b/packages/backend/src/quizzes/quizzes.module.ts new file mode 100644 index 0000000..6672409 --- /dev/null +++ b/packages/backend/src/quizzes/quizzes.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { QuizzesController } from './quizzes.controller'; +import { QuizzesService } from './quizzes.service'; +import { Quiz } from './entity/quiz.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Category } from './entity/category.entity'; +import { ContainersModule } from '../containers/containers.module'; +import { SessionModule } from '../session/session.module'; +import { Keyword } from './entity/keyword.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Quiz, Category, Keyword]), + ContainersModule, + SessionModule, + ], + controllers: [QuizzesController], + providers: [QuizzesService], +}) +export class QuizzesModule {} diff --git a/packages/backend/src/quizzes/quizzes.service.spec.ts b/packages/backend/src/quizzes/quizzes.service.spec.ts new file mode 100644 index 0000000..6965f89 --- /dev/null +++ b/packages/backend/src/quizzes/quizzes.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { QuizzesService } from './quizzes.service'; + +describe('QuizzesService', () => { + let service: QuizzesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [QuizzesService], + }).compile(); + + service = module.get(QuizzesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/backend/src/quizzes/quizzes.service.ts b/packages/backend/src/quizzes/quizzes.service.ts new file mode 100644 index 0000000..f61c933 --- /dev/null +++ b/packages/backend/src/quizzes/quizzes.service.ts @@ -0,0 +1,152 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Quiz } from './entity/quiz.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QuizDto } from './dto/quiz.dto'; +import { CategoryQuizzesDto, QuizzesDto } from './dto/quizzes.dto'; +import { Category } from './entity/category.entity'; +import { ContainersService } from 'src/containers/containers.service'; +import { CommandResponseDto } from './dto/command-response.dto'; +import fs from 'fs'; +import * as Papa from 'papaparse'; +import { Keyword } from './entity/keyword.entity'; +import { Logger } from 'winston'; + +@Injectable() +export class QuizzesService { + constructor( + @InjectRepository(Quiz) + private quizRepository: Repository, + @InjectRepository(Category) + private categoryRepository: Repository, + @InjectRepository(Keyword) + private keywordRepository: Repository, + private containerService: ContainersService, + @Inject('winston') private readonly logger: Logger, + ) { + this.initQiuzzes(); + } + + private async initQiuzzes() { + this.logger.log('info', `read git-challenge-quiz.csv`); + const quizData = await this.readCsvFile('git-challenge-quiz.csv'); + + // Category와 Keyword를 먼저 생성 + const categories = {}; + const keywords = {}; + + for (const data of quizData) { + // Category 처리 + if (!categories[data.category]) { + let category = new Category(); + category.name = data.category; + this.logger.log('info', `add ${category.name} to categories`); + category = await this.categoryRepository.save(category); + categories[data.category] = category; + } + + // Keyword 처리 + const keywordList = data.keyword.split(','); + for (const kw of keywordList) { + const trimmedKw = kw.trim(); + if (!keywords[trimmedKw]) { + let keyword = new Keyword(); + keyword.keyword = trimmedKw; + this.logger.log('info', `add ${keyword.keyword} to keywords`); + keyword = await this.keywordRepository.save(keyword); + keywords[trimmedKw] = keyword; + } + } + } + + // Quiz 생성 + for (const data of quizData) { + const quiz = new Quiz(); + quiz.title = data.title; + quiz.description = data.description; + quiz.category = categories[data.category]; + quiz.id = data.id; + + const keywordList = data.keyword.split(',').map((kw) => kw.trim()); + quiz.keywords = keywordList.map((kw) => keywords[kw]); + + this.logger.log('info', `add ${quiz.title} to quizzes`); + await this.quizRepository.save(quiz); + } + } + + private readCsvFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + const file = fs.createReadStream(filePath); + const results = []; + + Papa.parse(file, { + header: true, + step: (row) => { + results.push(row.data); + }, + complete: () => { + resolve(results); + }, + error: (err) => { + reject(err); + }, + }); + }); + } + async getQuizById(id: number): Promise { + const quiz = await this.quizRepository.findOne({ + where: { id }, + relations: ['keywords', 'category'], + }); + + if (!quiz) { + throw new NotFoundException(`Quiz ${id} not found`); + } + + const quizDto: QuizDto = { + id: quiz.id, + title: quiz.title, + description: quiz.description, + keywords: quiz.keywords.map((keyword) => keyword.keyword), + category: quiz.category.name, + }; + + return quizDto; + } + + async findAllProblemsGroupedByCategory(): Promise { + const categories = await this.categoryRepository.find({ + relations: ['quizzes'], + }); + + const categoryQuizzesDtos: CategoryQuizzesDto[] = categories.map( + (category) => ({ + id: category.id, + category: category.name, + quizzes: category.quizzes + .map((quiz) => ({ + id: quiz.id, + title: quiz.title, + })) + .sort((a, b) => a.id - b.id), + }), + ); + + const quizzesDtos: QuizzesDto = { categories: categoryQuizzesDtos }; + + return quizzesDtos; + } + + async runGitCommand(command: string): Promise { + // 세션 검색 + + // 세션 없으면 or 세션에 할당된 컨테이너 없으면 컨테이너 생성 + // await this.containerService.getContainer(quizId); + + // 컨테이너 생성, 세션에 할당하고 DB 저장 + + // 최종 실행 + return this.containerService.runGitCommand('testContainer', command); + } +} diff --git a/packages/backend/src/session/schema/session.schema.ts b/packages/backend/src/session/schema/session.schema.ts new file mode 100644 index 0000000..55451f7 --- /dev/null +++ b/packages/backend/src/session/schema/session.schema.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +@Schema({ timestamps: true }) +export class Session extends Document { + // @Prop({ required: true }) + // createdAt: Date; + // + // @Prop({ required: true }) + // updatedAt: Date; + + @Prop() + deletedAt: Date | null; + + @Prop({ + required: true, + type: Map, + of: { + status: { type: String, required: true }, + logs: { type: [String], required: true }, + containerId: { type: String, default: '' }, + }, + }) + problems: Map< + number, + { + status: string; + logs: string[]; + containerId: string; + } + >; +} + +export const SessionSchema = SchemaFactory.createForClass(Session); diff --git a/packages/backend/src/session/session.module.ts b/packages/backend/src/session/session.module.ts new file mode 100644 index 0000000..ccd2a28 --- /dev/null +++ b/packages/backend/src/session/session.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { SessionService } from './session.service'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Session, SessionSchema } from './schema/session.schema'; + +@Module({ + imports: [ + MongooseModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + uri: configService.get('MONGODB_HOST'), + }), + inject: [ConfigService], + }), + MongooseModule.forFeature([{ name: Session.name, schema: SessionSchema }]), + ], + providers: [SessionService], + exports: [SessionService], +}) +export class SessionModule {} diff --git a/packages/backend/src/session/session.service.spec.ts b/packages/backend/src/session/session.service.spec.ts new file mode 100644 index 0000000..a351693 --- /dev/null +++ b/packages/backend/src/session/session.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SessionService } from './session.service'; + +describe('SessionService', () => { + let service: SessionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SessionService], + }).compile(); + + service = module.get(SessionService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/backend/src/session/session.service.ts b/packages/backend/src/session/session.service.ts new file mode 100644 index 0000000..7f78be1 --- /dev/null +++ b/packages/backend/src/session/session.service.ts @@ -0,0 +1,103 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Logger } from 'winston'; +import { Model } from 'mongoose'; +import { Session } from './schema/session.schema'; +import { ObjectId } from 'typeorm'; + +@Injectable() +export class SessionService { + constructor( + @InjectModel(Session.name) private sessionModel: Model, + @Inject('winston') private readonly logger: Logger, + ) { + const testSession = new this.sessionModel({ + problems: {}, + }); + testSession.save(); + } + + async createSession(): Promise { + const session = new this.sessionModel({ + problems: {}, + }); + return await session.save().then((session) => { + this.logger.log('info', `session ${session._id as ObjectId} created`); + return (session._id as ObjectId).toString('hex'); + }); + } + + async getContainerIdBySessionId( + sessionId: string, + problemId: number, + ): Promise { + const session = await this.getSessionById(sessionId); + + if (!session.problems.get(problemId)) { + session.problems.set(problemId, { + status: 'solving', + logs: [], + containerId: '', + }); + this.logger.log('info', `session ${session._id as ObjectId} updated`); + this.logger.log( + 'info', + `session's new quizId: ${problemId}, document created`, + ); + await session.save(); + } else { + this.logger.log( + 'info', + `containerId: ${session.problems.get(problemId)?.containerId}`, + ); + } + return session.problems.get(problemId)?.containerId; + } + + async setContainerBySessionId( + sessionId: string, + problemId: number, + containerId: string, + ): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + this.logger.log( + 'info', + `setting ${sessionId}'s containerId as ${containerId}`, + ); + session.problems.get(problemId).containerId = containerId; + session.save(); + } + + async pushLogBySessionId( + command: string, + sessionId: string, + problemId: number, + ): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + session.problems.get(problemId).logs.push(command); + session.save(); + } + + async deleteCommandHistory( + sessionId: string, + problemId: number, + ): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + session.problems.get(problemId).logs = []; + session.problems.get(problemId).containerId = ''; + session.save(); + } + + private async getSessionById(id: string): Promise { + return await this.sessionModel.findById(id); + } +} diff --git a/packages/backend/test/app.e2e-spec.ts b/packages/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..50cda62 --- /dev/null +++ b/packages/backend/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/packages/backend/test/jest-e2e.json b/packages/backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/packages/backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/packages/backend/tsconfig.build.json b/packages/backend/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/packages/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json new file mode 100644 index 0000000..71775ae --- /dev/null +++ b/packages/backend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + }, + "extends": "../../tsconfig.json" +} diff --git a/packages/frontend/.dockerignore b/packages/frontend/.dockerignore new file mode 100644 index 0000000..cbddf54 --- /dev/null +++ b/packages/frontend/.dockerignore @@ -0,0 +1,26 @@ +# 버전 관리 시스템 +.git +.gitignore + +# 노드 모듈 +node_modules + +# 로그 파일 +npm-debug.log +yarn-error.log + +# 빌드 디렉토리 +dist +build + +# 개발 도구 설정 +.editorconfig +*.env +*.env.local +*.env.development.local +*.env.test.local +*.env.production.local + +# OS 관련 파일 +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/packages/frontend/.eslintrc.json b/packages/frontend/.eslintrc.json new file mode 100644 index 0000000..f5197d2 --- /dev/null +++ b/packages/frontend/.eslintrc.json @@ -0,0 +1,43 @@ +{ + "extends": [ + "plugin:@typescript-eslint/recommended", + "airbnb", + "airbnb-typescript", + "next/core-web-vitals", + "plugin:storybook/recommended", + "prettier", + "plugin:import/recommended", + "plugin:import/typescript" + ], + "rules": { + "sort-imports": ["error", { "ignoreDeclarationSort": true }], + "import/order": [ + "error", + { + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + }, + "groups": [ + "builtin", + "external", + "internal", + "parent", + "sibling", + "index" + ] + } + ], + "react/require-default-props": "off", + "@typescript-eslint/no-use-before-define": "off", + "import/prefer-default-export": "off" + }, + "settings": { + "import/external-module-folders": [".yarn"] + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json" + } +} diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/frontend/.prettierrc b/packages/frontend/.prettierrc new file mode 100644 index 0000000..fd082dc --- /dev/null +++ b/packages/frontend/.prettierrc @@ -0,0 +1,20 @@ +{ + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "semi": true, + "singleQuote": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "singleAttributePerLine": false, + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "proseWrap": "preserve", + "insertPragma": false, + "printWidth": 80, + "requirePragma": false, + "tabWidth": 2, + "useTabs": false, + "embeddedLanguageFormatting": "auto" +} diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts new file mode 100644 index 0000000..5356678 --- /dev/null +++ b/packages/frontend/.storybook/main.ts @@ -0,0 +1,64 @@ +import type { StorybookConfig } from "@storybook/nextjs"; +import { VanillaExtractPlugin } from "@vanilla-extract/webpack-plugin"; +import MiniCssExtractPlugin from "mini-css-extract-plugin"; +import { join, dirname } from "path"; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, "package.json"))); +} +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + getAbsolutePath("@storybook/addon-links"), + getAbsolutePath("@storybook/addon-essentials"), + getAbsolutePath("@storybook/addon-onboarding"), + getAbsolutePath("@storybook/addon-interactions"), + ], + framework: { + name: getAbsolutePath("@storybook/nextjs"), + options: {}, + }, + docs: { + autodocs: "tag", + }, + // https://stackblitz.com/edit/sb-vanilla-extract-webpack?file=.storybook%2Fmain.ts + webpackFinal(config) { + // Add Vanilla-Extract and MiniCssExtract Plugins + config.plugins?.push( + new VanillaExtractPlugin(), + new MiniCssExtractPlugin(), + ); + + // Exclude vanilla extract files from regular css processing + config.module?.rules?.forEach((rule) => { + if ( + typeof rule !== "string" && + rule && + rule.test instanceof RegExp && + rule.test.test("test.css") + ) { + rule.exclude = /\.vanilla\.css$/i; + } + }); + + config.module?.rules?.push({ + test: /\.vanilla\.css$/i, // Targets only CSS files generated by vanilla-extract + use: [ + MiniCssExtractPlugin.loader, + { + loader: require.resolve("css-loader"), + options: { + url: false, // Required as image imports should be handled via JS/TS import statements + }, + }, + ], + }); + + return config; + }, +}; +export default config; diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts new file mode 100644 index 0000000..47ec9f6 --- /dev/null +++ b/packages/frontend/.storybook/preview.ts @@ -0,0 +1,44 @@ +import type { Preview } from "@storybook/react"; + +import { withThemeByDataAttribute } from "@storybook/addon-styling"; + +import "../src/design-system/styles/global.css"; +import "../src/design-system/styles/reset.css"; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + // https://storybook.js.org/docs/react/essentials/toolbars-and-globals#global-types-and-the-toolbar-annotation + globalTypes: { + theme: { + defaultValue: "light", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: ["light", "dark"], + dynamicTitle: true, + }, + }, + }, +}; + +// Not-using-React-or-JSX?-No-problem! https://storybook.js.org/blog/styling-addon-configure-styles-and-themes-in-storybook +export const decorators = [ + withThemeByDataAttribute({ + themes: { + light: "light", + dark: "dark", + }, + defaultTheme: "light", + attributeName: "data-theme", + }), +]; + +export default preview; diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile new file mode 100644 index 0000000..d8b6348 --- /dev/null +++ b/packages/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18.17.1 + +WORKDIR /app + +COPY . . + +RUN corepack enable + +WORKDIR /app/packages/frontend + +RUN yarn install +RUN yarn build + +WORKDIR /app + +EXPOSE 3000 + +CMD ["sh", "-c", "cd packages/frontend && yarn run start"] \ No newline at end of file diff --git a/packages/frontend/README.md b/packages/frontend/README.md new file mode 100644 index 0000000..a75ac52 --- /dev/null +++ b/packages/frontend/README.md @@ -0,0 +1,40 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/packages/frontend/jest.config.mjs b/packages/frontend/jest.config.mjs new file mode 100644 index 0000000..7c1b9f9 --- /dev/null +++ b/packages/frontend/jest.config.mjs @@ -0,0 +1,18 @@ +import nextJest from 'next/jest.js' + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}) + +// Add any custom config to be passed to Jest +/** @type {import('jest').Config} */ +const config = { + // Add more setup options before each test is run + // setupFilesAfterEnv: ['/jest.setup.js'], + + testEnvironment: 'jest-environment-jsdom', +} + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +export default createJestConfig(config) \ No newline at end of file diff --git a/packages/frontend/next.config.js b/packages/frontend/next.config.js new file mode 100644 index 0000000..3a6b13c --- /dev/null +++ b/packages/frontend/next.config.js @@ -0,0 +1,18 @@ +const { createVanillaExtractPlugin } = require("@vanilla-extract/next-plugin"); + +const withVanillaExtract = createVanillaExtractPlugin(); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: false, // react-toastify toast 두 번 렌더링되는 문제 + async rewrites() { + return [ + { + source: "/api/v1/:path*", + destination: `https://git-challenge.com/api/v1/:path*`, + }, + ]; + }, +}; + +module.exports = withVanillaExtract(nextConfig); diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000..46b2a4d --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,70 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "jest", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "prettier --write", + "eslint --cache --fix" + ] + }, + "dependencies": { + "@storybook/react": "^7.5.3", + "@vanilla-extract/css": "^1.14.0", + "axios": "^1.6.2", + "next": "14.0.2", + "react": "^18", + "react-dom": "^18", + "react-icons": "^4.12.0", + "react-toastify": "^9.1.3" + }, + "devDependencies": { + "@storybook/addon-essentials": "^7.5.3", + "@storybook/addon-interactions": "^7.5.3", + "@storybook/addon-links": "^7.5.3", + "@storybook/addon-onboarding": "^1.0.8", + "@storybook/addon-styling": "^1.3.7", + "@storybook/blocks": "^7.5.3", + "@storybook/nextjs": "^7.5.3", + "@storybook/testing-library": "^0.2.2", + "@testing-library/jest-dom": "^6.1.4", + "@testing-library/react": "^14.1.0", + "@types/jest": "^29.5.8", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "@vanilla-extract/next-plugin": "^2.3.2", + "@vanilla-extract/webpack-plugin": "^2.3.1", + "css-loader": "^6.8.1", + "eslint": "^8", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-next": "14.0.2", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-storybook": "^0.6.15", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.1.0", + "mini-css-extract-plugin": "^2.7.6", + "msw": "1.3.2", + "prettier": "^3.1.0", + "storybook": "^7.5.3", + "typescript": "5.0.0-beta", + "webpack": "^5.89.0" + } +} diff --git a/packages/frontend/public/dark-logo.svg b/packages/frontend/public/dark-logo.svg new file mode 100644 index 0000000..48b3276 --- /dev/null +++ b/packages/frontend/public/dark-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/frontend/public/favicon.ico b/packages/frontend/public/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/packages/frontend/public/favicon.ico differ diff --git a/packages/frontend/public/light-logo.svg b/packages/frontend/public/light-logo.svg new file mode 100644 index 0000000..7c8243e --- /dev/null +++ b/packages/frontend/public/light-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/frontend/public/next.svg b/packages/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/packages/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/public/vercel.svg b/packages/frontend/public/vercel.svg new file mode 100644 index 0000000..d2f8422 --- /dev/null +++ b/packages/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/apis/base.ts b/packages/frontend/src/apis/base.ts new file mode 100644 index 0000000..051defe --- /dev/null +++ b/packages/frontend/src/apis/base.ts @@ -0,0 +1,9 @@ +import axios from "axios"; + +const apiVersion = "/api/v1"; + +const instance = axios.create({ + baseURL: apiVersion, +}); + +export { instance }; diff --git a/packages/frontend/src/apis/quizzes.ts b/packages/frontend/src/apis/quizzes.ts new file mode 100644 index 0000000..1f579ed --- /dev/null +++ b/packages/frontend/src/apis/quizzes.ts @@ -0,0 +1,27 @@ +import { API_PATH } from "../constants/path"; +import { Categories, Command, Quiz } from "../types/quiz"; + +import { instance } from "./base"; + +export const quizAPI = { + postCommand: async ({ id, command }: PostCommandRequest) => { + const { data } = await instance.post( + `${API_PATH.QUIZZES}/${id}/command`, + { command }, + ); + return data; + }, + getQuiz: async (id: number) => { + const { data } = await instance.get(`${API_PATH.QUIZZES}/${id}`); + return data; + }, + getCategories: async () => { + const { data } = await instance.get(API_PATH.QUIZZES); + return data; + }, +}; + +type PostCommandRequest = { + id: number; + command: string; +}; diff --git a/packages/frontend/src/components/demo/Demo.css.ts b/packages/frontend/src/components/demo/Demo.css.ts new file mode 100644 index 0000000..0139304 --- /dev/null +++ b/packages/frontend/src/components/demo/Demo.css.ts @@ -0,0 +1,50 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../design-system/tokens/color"; +import { border } from "../../design-system/tokens/utils.css"; + +const containerPadding = 23; + +export const main = style({ + marginBottom: "54px", +}); + +export const mainInner = style([ + border.all, + { + borderTop: 0, + marginBottom: "17px", + }, +]); + +export const gitGraph = style({ + width: "50%", + borderRight: `1px solid ${color.$semantic.border}`, + textAlign: "center", +}); + +export const quizContentContainer = style({ + position: "relative", + width: "50%", + height: "400px", + padding: containerPadding, + "@media": { + "(min-width: 1920px) and (max-width: 2559px)": { + height: "500px", + }, + }, +}); + +export const commandAccordion = style({ + marginTop: "12px", +}); + +export const checkAnswerButton = style({ + position: "absolute", + right: containerPadding, + bottom: containerPadding, +}); + +export const submitButton = style({ + textAlign: "end", +}); diff --git a/packages/frontend/src/components/demo/Demo.tsx b/packages/frontend/src/components/demo/Demo.tsx new file mode 100644 index 0000000..8d2b729 --- /dev/null +++ b/packages/frontend/src/components/demo/Demo.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; + +import { quizAPI } from "../../apis/quizzes"; +import { Button } from "../../design-system/components/common"; +import { flex } from "../../design-system/tokens/utils.css"; +import quizContentMockData from "../../mocks/apis/data/quizContentData"; +import { TerminalContentType } from "../../types/terminalType"; +import { CommandAccordion, QuizContent } from "../quiz"; +import { Terminal } from "../terminal"; + +import * as styles from "./Demo.css"; + +const { category, title, description, keywords } = quizContentMockData; + +export default function Demo() { + const [contentArray, setContentArray] = useState([]); + + const handleTerminal = async (input: string) => { + const data = await quizAPI.postCommand({ + id: 1, + command: input, + }); + setContentArray([ + ...contentArray, + { type: "stdin", content: input }, + { type: "stdout", content: data.message }, + ]); + }; + + return ( +
+
+
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+ ); +} diff --git a/packages/frontend/src/components/demo/index.ts b/packages/frontend/src/components/demo/index.ts new file mode 100644 index 0000000..dc3e686 --- /dev/null +++ b/packages/frontend/src/components/demo/index.ts @@ -0,0 +1,3 @@ +import Demo from "./Demo"; + +export default Demo; diff --git a/packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.css.ts b/packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.css.ts new file mode 100644 index 0000000..51a3412 --- /dev/null +++ b/packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.css.ts @@ -0,0 +1,7 @@ +import { style } from "@vanilla-extract/css"; + +const badgeGroupLayout = style({ + marginTop: "6px", +}); + +export default badgeGroupLayout; diff --git a/packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.tsx b/packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.tsx new file mode 100644 index 0000000..a7b84cb --- /dev/null +++ b/packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.tsx @@ -0,0 +1,33 @@ +import { + Accordion, + BadgeGroup, +} from "../../../design-system/components/common"; + +import badgeGroupLayout from "./CommandAccordion.css"; + +interface CommandAccordionProps { + width?: number | string; + items: string[]; +} + +export default function CommandAccordion({ + width = "100%", + items, +}: CommandAccordionProps) { + return ( + + + + {({ open }) => <>핵심명령어 {open ? "숨기기" : "보기"}} + +
+ +
+
+
+ ); +} + +function toLabelProps(item: string) { + return { label: item }; +} diff --git a/packages/frontend/src/components/quiz/CommandAccordion/index.ts b/packages/frontend/src/components/quiz/CommandAccordion/index.ts new file mode 100644 index 0000000..adb7fd4 --- /dev/null +++ b/packages/frontend/src/components/quiz/CommandAccordion/index.ts @@ -0,0 +1,3 @@ +import CommandAccordion from "./CommandAccordion"; + +export default CommandAccordion; diff --git a/packages/frontend/src/components/quiz/QuizContent/QuizContent.css.ts b/packages/frontend/src/components/quiz/QuizContent/QuizContent.css.ts new file mode 100644 index 0000000..a60aaa7 --- /dev/null +++ b/packages/frontend/src/components/quiz/QuizContent/QuizContent.css.ts @@ -0,0 +1,34 @@ +import { globalStyle, style } from "@vanilla-extract/css"; + +import color from "../../../design-system/tokens/color"; +import typography from "../../../design-system/tokens/typography"; + +export const strong = style([ + typography.$semantic.title3Bold, + { color: color.$scale.grey800 }, +]); + +export const description = style([ + typography.$semantic.body2Regular, + { + marginTop: 10, + height: 60, + padding: "0 8px 4px 0", + color: color.$scale.grey700, + overflowY: "auto", + whiteSpace: "break-spaces", + "@media": { + "(min-width: 1920px) and (max-width: 2559px)": { + height: 250, + }, + }, + }, +]); + +globalStyle(`${description} code`, { + borderRadius: 4, + paddingLeft: 4, + paddingRight: 4, + color: color.$scale.coral500, + backgroundColor: color.$scale.grey100, +}); diff --git a/packages/frontend/src/components/quiz/QuizContent/QuizContent.tsx b/packages/frontend/src/components/quiz/QuizContent/QuizContent.tsx new file mode 100644 index 0000000..60d8d80 --- /dev/null +++ b/packages/frontend/src/components/quiz/QuizContent/QuizContent.tsx @@ -0,0 +1,27 @@ +import toCodeTag from "../../../utils/mapper"; +import QuizLocation from "../QuizLocation"; + +import * as styles from "./QuizContent.css"; + +interface QuizContentProps { + title: string; + description: string; + category: string; +} + +export default function QuizContent({ + title, + description, + category, +}: QuizContentProps) { + return ( + <> + + 문제 +

+ + ); +} diff --git a/packages/frontend/src/components/quiz/QuizContent/index.ts b/packages/frontend/src/components/quiz/QuizContent/index.ts new file mode 100644 index 0000000..4082b2c --- /dev/null +++ b/packages/frontend/src/components/quiz/QuizContent/index.ts @@ -0,0 +1,3 @@ +import QuizContent from "./QuizContent"; + +export default QuizContent; diff --git a/packages/frontend/src/components/quiz/QuizLocation/QuizLocation.css.ts b/packages/frontend/src/components/quiz/QuizLocation/QuizLocation.css.ts new file mode 100644 index 0000000..d0d24fe --- /dev/null +++ b/packages/frontend/src/components/quiz/QuizLocation/QuizLocation.css.ts @@ -0,0 +1,20 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../../design-system/tokens/color"; +import typography from "../../../design-system/tokens/typography"; +import * as utils from "../../../design-system/tokens/utils.css"; + +export const list = style([ + typography.$semantic.caption1Regular, + utils.flex, + { + justifyContent: "flex-start", + alignItems: "center", + marginBottom: 7, + color: color.$scale.grey700, + }, +]); + +export const icon = style({ + padding: "0 4px", +}); diff --git a/packages/frontend/src/components/quiz/QuizLocation/QuizLocation.tsx b/packages/frontend/src/components/quiz/QuizLocation/QuizLocation.tsx new file mode 100644 index 0000000..a76c554 --- /dev/null +++ b/packages/frontend/src/components/quiz/QuizLocation/QuizLocation.tsx @@ -0,0 +1,36 @@ +import { BsChevronRight } from "react-icons/bs"; + +import { flexAlignCenter } from "../../../design-system/tokens/utils.css"; + +import { icon as iconStyle, list as listStyle } from "./QuizLocation.css"; + +interface QuizLocationProps { + items: string[]; +} + +export default function QuizLocation({ items }: QuizLocationProps) { + const { length } = items; + + return ( +

    + {items.map((item, index) => ( +
  1. + {item} + {!isLast(index, length) && } +
  2. + ))} +
+ ); +} + +function ChevronRight() { + return ( + + + + ); +} + +function isLast(index: number, length: number) { + return index === length - 1; +} diff --git a/packages/frontend/src/components/quiz/QuizLocation/index.ts b/packages/frontend/src/components/quiz/QuizLocation/index.ts new file mode 100644 index 0000000..2cd9f94 --- /dev/null +++ b/packages/frontend/src/components/quiz/QuizLocation/index.ts @@ -0,0 +1,3 @@ +import QuizLocation from "./QuizLocation"; + +export default QuizLocation; diff --git a/packages/frontend/src/components/quiz/index.ts b/packages/frontend/src/components/quiz/index.ts new file mode 100644 index 0000000..2e8fa5c --- /dev/null +++ b/packages/frontend/src/components/quiz/index.ts @@ -0,0 +1,3 @@ +export { default as QuizContent } from "./QuizContent"; +export { default as QuizLocation } from "./QuizLocation"; +export { default as CommandAccordion } from "./CommandAccordion"; diff --git a/packages/frontend/src/components/terminal/CommandInput.tsx b/packages/frontend/src/components/terminal/CommandInput.tsx new file mode 100644 index 0000000..e2a1c40 --- /dev/null +++ b/packages/frontend/src/components/terminal/CommandInput.tsx @@ -0,0 +1,35 @@ +import { type KeyboardEventHandler, forwardRef } from "react"; + +import classnames from "../../utils/classnames"; + +import Prompt from "./Prompt"; +import * as styles from "./Terminal.css"; + +interface CommandInputProps { + handleInput: KeyboardEventHandler; +} + +const CommandInput = forwardRef( + ({ handleInput }, ref) => ( +
+ + Enter git command + + + +
+ ) +); + +CommandInput.displayName = "CommandInput"; + +export default CommandInput; diff --git a/packages/frontend/src/components/terminal/Prompt.tsx b/packages/frontend/src/components/terminal/Prompt.tsx new file mode 100644 index 0000000..9ccda9f --- /dev/null +++ b/packages/frontend/src/components/terminal/Prompt.tsx @@ -0,0 +1,5 @@ +import { prompt as promptStyle } from "./Terminal.css"; + +export default function Prompt() { + return $; +} diff --git a/packages/frontend/src/components/terminal/Terminal.css.ts b/packages/frontend/src/components/terminal/Terminal.css.ts new file mode 100644 index 0000000..394bc73 --- /dev/null +++ b/packages/frontend/src/components/terminal/Terminal.css.ts @@ -0,0 +1,63 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../design-system/tokens/color"; +import typography from "../../design-system/tokens/typography"; +import { flexAlignCenter } from "../../design-system/tokens/utils.css"; + +const hrHeight = "20px"; + +export const container = style([ + typography.$semantic.code, + { + height: 180, + width: "100%", + }, +]); + +export const hr = style({ + height: hrHeight, + margin: 0, + border: "none", + borderTop: `1px solid ${color.$semantic.border}`, + borderBottom: `1px solid ${color.$semantic.border}`, + backgroundColor: color.$scale.grey100, +}); + +export const terminalContainer = style([ + typography.$semantic.caption1Regular, + { + height: `calc(100% - ${hrHeight})`, + padding: "10px 10px", + overflowY: "auto", + color: color.$scale.grey900, + backgroundColor: color.$scale.grey00, + whiteSpace: "break-spaces", + }, +]); + +export const commandInputContainer = style([ + flexAlignCenter, + { + width: "100%", + position: "relative", + }, +]); + +export const prompt = style({ + position: "absolute", + top: 1, + left: 0, + paddingRight: 4, +}); + +export const stdinContainer = style({ position: "relative" }); + +export const stdin = style({ + display: "block", + textIndent: 10, +}); + +export const commandInput = style({ + flex: 1, + outline: 0, +}); diff --git a/packages/frontend/src/components/terminal/Terminal.tsx b/packages/frontend/src/components/terminal/Terminal.tsx new file mode 100644 index 0000000..6cc8400 --- /dev/null +++ b/packages/frontend/src/components/terminal/Terminal.tsx @@ -0,0 +1,51 @@ +import { type KeyboardEventHandler, useEffect, useRef } from "react"; + +import { ENTER_KEY } from "../../constants/event"; +import type { TerminalContentType } from "../../types/terminalType"; +import { scrollIntoView } from "../../utils/scroll"; + +import CommandInput from "./CommandInput"; +import * as styles from "./Terminal.css"; +import TerminalContent from "./TerminalContent"; + +interface TerminalProps { + contentArray: TerminalContentType[]; + onTerminal: (input: string) => Promise; +} + +export default function Terminal({ contentArray, onTerminal }: TerminalProps) { + const inputRef = useRef(null); + + const handleStandardInput: KeyboardEventHandler = async (event) => { + const { key, currentTarget } = event; + if (key !== ENTER_KEY) { + return; + } + + event.preventDefault(); + + const value = (currentTarget.textContent ?? "").trim(); + if (!value) { + return; + } + + await onTerminal(value); + + currentTarget.textContent = ""; + }; + + useEffect(() => { + scrollIntoView(inputRef); + }, [contentArray]); + + return ( +
+
+
+ + + +
+
+ ); +} diff --git a/packages/frontend/src/components/terminal/TerminalContent.tsx b/packages/frontend/src/components/terminal/TerminalContent.tsx new file mode 100644 index 0000000..7a2f2c5 --- /dev/null +++ b/packages/frontend/src/components/terminal/TerminalContent.tsx @@ -0,0 +1,49 @@ +import type { TerminalContentType } from "../../types/terminalType"; + +import Prompt from "./Prompt"; +import * as styles from "./Terminal.css"; + +interface TerminalContentProps { + contentArray: TerminalContentType[]; +} + +export default function TerminalContent({ + contentArray, +}: TerminalContentProps) { + const content = contentArray.map(toTerminalContentComponent); + return
{content}
; +} + +const contentMap = { + stdin: StandardInputContent, + stdout: StandardOutputContent, +}; + +interface ContentProps { + content: string; +} + +function StandardInputContent({ content }: ContentProps) { + return ( +
+ + {content} +
+ ); +} + +function StandardOutputContent({ content }: ContentProps) { + return ( +
+ {content} +
+ ); +} + +function toTerminalContentComponent( + { type, content }: TerminalContentType, + index: number, +) { + const Content = contentMap[type]; + return ; +} diff --git a/packages/frontend/src/components/terminal/index.ts b/packages/frontend/src/components/terminal/index.ts new file mode 100644 index 0000000..1b8df7a --- /dev/null +++ b/packages/frontend/src/components/terminal/index.ts @@ -0,0 +1 @@ +export { default as Terminal } from "./Terminal"; diff --git a/packages/frontend/src/constants/event.ts b/packages/frontend/src/constants/event.ts new file mode 100644 index 0000000..c2518a7 --- /dev/null +++ b/packages/frontend/src/constants/event.ts @@ -0,0 +1,2 @@ +export const ESC_KEY = "Escape"; +export const ENTER_KEY = "Enter"; diff --git a/packages/frontend/src/constants/path.ts b/packages/frontend/src/constants/path.ts new file mode 100644 index 0000000..49cdb38 --- /dev/null +++ b/packages/frontend/src/constants/path.ts @@ -0,0 +1,11 @@ +const BROWSWER_PATH = { + MAIN: "/", + QUIZZES: "/quizzes", + SHARE: "/share", +} as const; + +const API_PATH = { + QUIZZES: "/quizzes", +} as const; + +export { BROWSWER_PATH, API_PATH }; diff --git a/packages/frontend/src/design-system/components/common/Accordion/Accordion.css.ts b/packages/frontend/src/design-system/components/common/Accordion/Accordion.css.ts new file mode 100644 index 0000000..2ef2749 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/Accordion.css.ts @@ -0,0 +1,43 @@ +import { style, styleVariants } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import typography from "../../../tokens/typography"; +import { flex } from "../../../tokens/utils.css"; + +export const summaryText = { + sm: typography.$semantic.caption1Regular, + md: typography.$semantic.title3Bold, +}; + +export const summaryColor = styleVariants({ + black: { + color: color.$scale.grey800, + }, + grey: { + color: color.$scale.grey500, + }, +}); + +const summaryContainerBase = style([ + flex, + { + justifyContent: "flex-start", + alignItems: "center", + cursor: "pointer", + }, +]); + +export const summaryContainer = styleVariants({ + sm: [ + summaryContainerBase, + { + gap: 5, + }, + ], + md: [ + summaryContainerBase, + { + gap: 13, + }, + ], +}); diff --git a/packages/frontend/src/design-system/components/common/Accordion/Accordion.tsx b/packages/frontend/src/design-system/components/common/Accordion/Accordion.tsx new file mode 100644 index 0000000..c66ab34 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/Accordion.tsx @@ -0,0 +1,26 @@ +import { type ReactNode } from "react"; + +import { AccordionContextProvider } from "./AccordionContextProvider"; +import AccordionDetails from "./AccordionDetails"; +import AccordionSummary from "./AccordionSummary"; + +interface AccordionProps { + width?: number | string; + open?: boolean; + children: ReactNode; +} + +export default function Accordion({ + width = 200, + open: initOpen = false, + children, +}: AccordionProps) { + return ( + + {children} + + ); +} + +Accordion.Details = AccordionDetails; +Accordion.Summary = AccordionSummary; diff --git a/packages/frontend/src/design-system/components/common/Accordion/AccordionContextProvider.tsx b/packages/frontend/src/design-system/components/common/Accordion/AccordionContextProvider.tsx new file mode 100644 index 0000000..17cbf50 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/AccordionContextProvider.tsx @@ -0,0 +1,56 @@ +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +export type AccordionContextType = { + open: boolean; + width: number | string; + onChange: (open: boolean) => void; +}; + +const AccordionContext = createContext(null); + +export function useAccordion() { + const accordionData = useContext(AccordionContext); + return accordionData; +} + +export function AccordionContextProvider({ + open: initOpen, + width, + children, +}: { + open: boolean; + width: number | string; + children: ReactNode; +}) { + const [open, setOpen] = useState(initOpen); + + const handleChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + }, + [setOpen], + ); + + const accordionContextValue = useMemo( + () => ({ open, onChange: handleChange, width }), + [open, handleChange, width], + ); + + useEffect(() => { + setOpen(initOpen); + }, [initOpen]); + + return ( + + {children} + + ); +} diff --git a/packages/frontend/src/design-system/components/common/Accordion/AccordionDetails.tsx b/packages/frontend/src/design-system/components/common/Accordion/AccordionDetails.tsx new file mode 100644 index 0000000..8f40e77 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/AccordionDetails.tsx @@ -0,0 +1,52 @@ +import { type ReactNode, useEffect, useRef } from "react"; + +import { useAccordion } from "./AccordionContextProvider"; + +interface AccordionDetailsProps { + children: ReactNode; +} + +export default function AccordionDetails({ children }: AccordionDetailsProps) { + const detailsRef = useRef(null); + const accordionContext = useAccordion(); + if (!accordionContext) { + throw new Error( + "Accordion.Details 컴포넌트는 Accordion 컴포넌트로 래핑해야 합니다." + ); + } + + const { width, open, onChange } = accordionContext; + + useEffect(() => { + if (!detailsRef.current) { + return undefined; + } + + const $details = detailsRef.current; + + const handleBeforeMatch = (event: Event) => { + const { target } = event; + if (!(target instanceof HTMLDetailsElement)) { + return; + } + + const detailsOpen = target.open; + if (detailsOpen === open) { + return; + } + + onChange(detailsOpen); + }; + + $details.addEventListener("toggle", handleBeforeMatch); + return () => { + $details.removeEventListener("toggle", handleBeforeMatch); + }; + }, [onChange, open]); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/Accordion/AccordionSummary.tsx b/packages/frontend/src/design-system/components/common/Accordion/AccordionSummary.tsx new file mode 100644 index 0000000..d68d100 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/AccordionSummary.tsx @@ -0,0 +1,57 @@ +import { MouseEventHandler, type ReactNode } from "react"; + +import classnames from "../../../../utils/classnames"; + +import * as styles from "./Accordion.css"; +import { + type AccordionContextType, + useAccordion, +} from "./AccordionContextProvider"; +import ChevronIcon from "./ChevronIcon/ChevronIcon"; + +interface AccordionSummaryProps { + color?: "black" | "grey"; + size?: "md" | "sm"; + children: ReactNode | RenderComponentType; +} + +type RenderComponentType = (props: AccordionContextType) => ReactNode; + +export default function AccordionSummary({ + color = "black", + size = "md", + children, +}: AccordionSummaryProps) { + const accordionContext = useAccordion(); + if (!accordionContext) { + throw new Error( + "Accordion.Summary 컴포넌트는 Accordion 컴포넌트로 래핑해야 합니다." + ); + } + + const summaryStyle = classnames( + styles.summaryText[size], + styles.summaryColor[color], + styles.summaryContainer[size] + ); + + const { open, onChange } = accordionContext; + const chevronType = open ? "up" : "down"; + + const handleChange: MouseEventHandler = (event) => { + event.preventDefault(); + onChange(!open); + }; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + +
+ {children instanceof Function ? children(accordionContext) : children} +
+
+ +
+
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.css.ts b/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.css.ts new file mode 100644 index 0000000..e53ffb8 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.css.ts @@ -0,0 +1,22 @@ +import { style, styleVariants } from "@vanilla-extract/css"; + +import { border, flexCenter } from "../../../../tokens/utils.css"; + +export const containerBase = style([ + flexCenter, + border.all, + { + borderRadius: "50%", + }, +]); + +export const containerVariants = styleVariants({ + sm: { + width: 18, + height: 18, + }, + md: { + width: 25, + height: 25, + }, +}); diff --git a/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.tsx b/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.tsx new file mode 100644 index 0000000..fa1fa00 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.tsx @@ -0,0 +1,36 @@ +import { BsChevronDown, BsChevronUp } from "react-icons/bs"; + +import classnames from "../../../../../utils/classnames"; +import color from "../../../../tokens/color"; + +import * as styles from "./ChevronIcon.css"; + +interface ChevronIconProps { + size: "md" | "sm"; + type: keyof typeof chevronIconMap; +} + +export default function ChevronIcon({ type, size }: ChevronIconProps) { + const containerStyle = classnames( + styles.containerBase, + styles.containerVariants[size] + ); + + const Chevron = chevronIconMap[type]; + + return ( +
+ +
+ ); +} + +const chevronIconMap = { + up: BsChevronUp, + down: BsChevronDown, +}; + +const chevronSizeMap = { + sm: 10, + md: 14, +}; diff --git a/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/index.ts b/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/index.ts new file mode 100644 index 0000000..16a2269 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/index.ts @@ -0,0 +1,3 @@ +import ChevronIcon from "./ChevronIcon"; + +export default ChevronIcon; diff --git a/packages/frontend/src/design-system/components/common/Accordion/index.ts b/packages/frontend/src/design-system/components/common/Accordion/index.ts new file mode 100644 index 0000000..6fce718 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Accordion/index.ts @@ -0,0 +1,2 @@ +export { default as Accordion } from "./Accordion"; +export { useAccordion } from "./AccordionContextProvider"; diff --git a/packages/frontend/src/design-system/components/common/Badge/Badge.css.ts b/packages/frontend/src/design-system/components/common/Badge/Badge.css.ts new file mode 100644 index 0000000..34407df --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Badge/Badge.css.ts @@ -0,0 +1,45 @@ +import { style, styleVariants } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import typography from "../../../tokens/typography"; + +export const container = style({ + display: "flex", + gap: 10, +}); + +export const badgeBase = style([ + typography.$semantic.caption2Regular, + { + height: 22, + padding: "3px 7px", + borderRadius: 5, + }, +]); + +export const badgeVariants = styleVariants({ + orange: { + color: color.$semantic.badgeOrange, + backgroundColor: color.$semantic.badgeOrangeBg, + }, + yellow: { + color: color.$semantic.badgeYellow, + backgroundColor: color.$semantic.badgeYellowBg, + }, + green: { + color: color.$semantic.badgeGreen, + backgroundColor: color.$semantic.badgeGreenBg, + }, + teal: { + color: color.$semantic.badgeTeal, + backgroundColor: color.$semantic.badgeTealBg, + }, + blue: { + color: color.$semantic.badgeBlue, + backgroundColor: color.$semantic.badgeBlueBg, + }, + purple: { + color: color.$semantic.badgePurple, + backgroundColor: color.$semantic.badgePurpleBg, + }, +}); diff --git a/packages/frontend/src/design-system/components/common/Badge/Badge.tsx b/packages/frontend/src/design-system/components/common/Badge/Badge.tsx new file mode 100644 index 0000000..20d91e9 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Badge/Badge.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; + +import classnames from "../../../../utils/classnames"; + +import { badgeVariants } from "./Badge.css"; +import * as styles from "./Badge.css"; + +export type BadgeVariantType = keyof typeof badgeVariants; + +export interface BadgeProps { + variant: BadgeVariantType; + label: ReactNode; +} + +export function Badge({ variant, label }: BadgeProps) { + const badgeStyle = classnames( + styles.badgeBase, + styles.badgeVariants[variant], + ); + return {label}; +} diff --git a/packages/frontend/src/design-system/components/common/Badge/BadgeGroup.tsx b/packages/frontend/src/design-system/components/common/Badge/BadgeGroup.tsx new file mode 100644 index 0000000..65cf316 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Badge/BadgeGroup.tsx @@ -0,0 +1,23 @@ +import { objectKeys } from "../../../../utils/types"; + +import { Badge } from "./Badge"; +import * as styles from "./Badge.css"; + +const variants = objectKeys(styles.badgeVariants); +export interface BadgeGroupProps { + items: { label: string }[]; +} + +export function BadgeGroup({ items }: BadgeGroupProps) { + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/Badge/index.ts b/packages/frontend/src/design-system/components/common/Badge/index.ts new file mode 100644 index 0000000..94a4849 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Badge/index.ts @@ -0,0 +1,2 @@ +export { Badge } from "./Badge"; +export { BadgeGroup } from "./BadgeGroup"; diff --git a/packages/frontend/src/design-system/components/common/Button/Button.css.ts b/packages/frontend/src/design-system/components/common/Button/Button.css.ts new file mode 100644 index 0000000..0c0a7fe --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Button/Button.css.ts @@ -0,0 +1,91 @@ +import { style, styleVariants } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import typography from "../../../tokens/typography"; + +export const buttonBase = style([ + typography.$semantic.title4Regular, + { + height: 42, + border: "1px solid transparent", + borderRadius: 8, + padding: "8px 13px", + + ":disabled": { + borderColor: color.$semantic.bgDisabled, + color: color.$semantic.textDisabled, + backgroundColor: color.$semantic.bgDisabled, + }, + }, +]); + +export const buttonVariants = styleVariants({ + primaryFill: { + color: color.$semantic.textWhite, + backgroundColor: color.$semantic.primary, + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: color.$semantic.primaryHover, + }, + }, + }, + + secondaryFill: { + color: color.$semantic.textWhite, + backgroundColor: color.$semantic.secondary, + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: color.$semantic.secondaryHover, + }, + }, + }, + + primaryLine: { + border: `1px solid ${color.$semantic.primary}`, + color: color.$semantic.primary, + backgroundColor: color.$semantic.bgWhite, + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: color.$semantic.primaryLow, + }, + }, + }, + + secondaryLine: { + border: `1px solid ${color.$semantic.secondary}`, + color: color.$semantic.secondary, + backgroundColor: color.$semantic.bgWhite, + + selectors: { + "&:hover:not(:disabled)": { + color: color.$semantic.secondary, + backgroundColor: color.$semantic.secondaryLow, + }, + }, + }, + + primaryLow: { + color: color.$semantic.primary, + backgroundColor: color.$semantic.primaryLow, + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: color.$semantic.primaryLowHover, + }, + }, + }, + + secondaryLow: { + color: color.$semantic.secondary, + backgroundColor: color.$semantic.secondaryLow, + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: color.$semantic.secondaryLowHover, + }, + }, + }, +}); diff --git a/packages/frontend/src/design-system/components/common/Button/Button.stories.tsx b/packages/frontend/src/design-system/components/common/Button/Button.stories.tsx new file mode 100644 index 0000000..230080f --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Button/Button.stories.tsx @@ -0,0 +1,37 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { Button } from "./Button"; + +const meta: Meta = { + title: "Button", + component: Button, + argTypes: { + onClick: { action: "clicked" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: { + variant: "primaryFill", + children: "variants", + }, +}; + +export const FullWidth: Story = { + args: { + variant: "primaryFill", + children: "full width", + full: true, + }, +}; + +export const Disabled: Story = { + args: { + variant: "primaryFill", + children: "disabled", + disabled: true, + }, +}; diff --git a/packages/frontend/src/design-system/components/common/Button/Button.tsx b/packages/frontend/src/design-system/components/common/Button/Button.tsx new file mode 100644 index 0000000..624c136 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Button/Button.tsx @@ -0,0 +1,44 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import classnames from "../../../../utils/classnames"; +import { widthFull } from "../../../tokens/utils.css"; + +import * as styles from "./Button.css"; + +export type ButtonVariantType = keyof typeof styles.buttonVariants; + +export interface ButtonProps + extends Pick< + ButtonHTMLAttributes, + "type" | "disabled" | "onClick" + > { + full?: boolean; + variant: ButtonVariantType; + children: ReactNode; +} + +export function Button({ + full = false, + variant, + children, + type = "button", + disabled = false, + onClick, +}: ButtonProps) { + const buttonStyle = classnames( + styles.buttonBase, + styles.buttonVariants[variant], + full ? widthFull : "", + ); + + return ( + + ); +} diff --git a/packages/frontend/src/design-system/components/common/Button/index.ts b/packages/frontend/src/design-system/components/common/Button/index.ts new file mode 100644 index 0000000..56dca6c --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Button/index.ts @@ -0,0 +1,3 @@ +import { Button } from "./Button"; + +export default Button; diff --git a/packages/frontend/src/design-system/components/common/Footer/Footer.css.ts b/packages/frontend/src/design-system/components/common/Footer/Footer.css.ts new file mode 100644 index 0000000..64d8eed --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Footer/Footer.css.ts @@ -0,0 +1,58 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import typography from "../../../tokens/typography"; +import { border, flex } from "../../../tokens/utils.css"; + +export const container = style([ + border.top, + { + backgroundColor: color.$scale.grey00, + }, +]); + +export const content = style([ + flex, + { + justifyContent: "space-between", + alignItems: "flex-end", + marginTop: 13, + }, +]); + +export const teamName = style([ + typography.$semantic.title2Bold, + { + color: color.$scale.grey800, + }, +]); + +export const teamInfo = style([ + typography.$semantic.caption1Regular, + { + flex: 1, + color: color.$scale.grey600, + }, +]); + +export const contact = style({ + color: color.$scale.grey600, +}); + +export const hr = style([ + border.top, + { + margin: "20px 0", + }, +]); + +export const rightsContainer = style([ + typography.$semantic.caption1Regular, + { + position: "relative", + textAlign: "center", + color: color.$scale.grey500, + }, +]); + +export const rights = style({ position: "absolute", left: 0 }); diff --git a/packages/frontend/src/design-system/components/common/Footer/Footer.tsx b/packages/frontend/src/design-system/components/common/Footer/Footer.tsx new file mode 100644 index 0000000..9de6294 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Footer/Footer.tsx @@ -0,0 +1,44 @@ +import { AiFillGithub } from "react-icons/ai"; + +import { footer as footerLayout } from "../../../tokens/layout.css"; + +import * as styles from "./Footer.css"; + +export default function Footer() { + return ( +
+
+ Merge Masters +
+
+
Team : Merge Master
+
+ Contact :{" "} + + Issues + +
+
+
+ + + +
+
+
+
+ © 2023 All rights reserved + 해당 웹사이트는 Chrome에 최적화되어 있습니다. +
+
+
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/Footer/index.ts b/packages/frontend/src/design-system/components/common/Footer/index.ts new file mode 100644 index 0000000..a64cd38 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Footer/index.ts @@ -0,0 +1,3 @@ +import Footer from "./Footer"; + +export default Footer; diff --git a/packages/frontend/src/design-system/components/common/Header/Header.css.ts b/packages/frontend/src/design-system/components/common/Header/Header.css.ts new file mode 100644 index 0000000..4cf35ea --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Header/Header.css.ts @@ -0,0 +1,16 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import { border, flexAlignCenter, widthMax } from "../../../tokens/utils.css"; + +export const borderBottom = border.bottom; + +export const container = style([ + flexAlignCenter, + widthMax, + { + height: "100%", + margin: "0 auto", + backgroundColor: color.$scale.grey00, + }, +]); diff --git a/packages/frontend/src/design-system/components/common/Header/Header.tsx b/packages/frontend/src/design-system/components/common/Header/Header.tsx new file mode 100644 index 0000000..f47ec13 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Header/Header.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; +import Link from "next/link"; + +import classnames from "../../../../utils/classnames"; +import { header as headerLayout } from "../../../tokens/layout.css"; + +import * as styles from "./Header.css"; + +export default function Header() { + const headerStyle = classnames(styles.borderBottom, headerLayout); + const logoSrc = "/light-logo.svg"; + + return ( +
+
+

+ + git-challenge-logo + +

+
+
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/Header/index.ts b/packages/frontend/src/design-system/components/common/Header/index.ts new file mode 100644 index 0000000..0d87fe1 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Header/index.ts @@ -0,0 +1,3 @@ +import Header from "./Header"; + +export default Header; diff --git a/packages/frontend/src/design-system/components/common/IconButton/IconButton.css.ts b/packages/frontend/src/design-system/components/common/IconButton/IconButton.css.ts new file mode 100644 index 0000000..e3af527 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/IconButton/IconButton.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css"; + +import { flex } from "../../../tokens/utils.css"; + +export const button = style([ + flex, + { + padding: 0, + backgroundColor: "transparent", + border: "none", + }, +]); diff --git a/packages/frontend/src/design-system/components/common/IconButton/IconButton.tsx b/packages/frontend/src/design-system/components/common/IconButton/IconButton.tsx new file mode 100644 index 0000000..5e642cb --- /dev/null +++ b/packages/frontend/src/design-system/components/common/IconButton/IconButton.tsx @@ -0,0 +1,24 @@ +import { ButtonHTMLAttributes, ReactNode } from "react"; + +import classnames from "../../../../utils/classnames"; + +import * as styles from "./IconButton.css"; + +export interface IconButtonProps + extends Pick, "onClick"> { + className?: string; + children: ReactNode; +} + +export default function IconButton({ + className = "", + children, + onClick, +}: IconButtonProps) { + const iconButtonStyle = classnames(styles.button, className); + return ( + + ); +} diff --git a/packages/frontend/src/design-system/components/common/IconButton/index.ts b/packages/frontend/src/design-system/components/common/IconButton/index.ts new file mode 100644 index 0000000..cfbe3bb --- /dev/null +++ b/packages/frontend/src/design-system/components/common/IconButton/index.ts @@ -0,0 +1,3 @@ +import IconButton from "./IconButton"; + +export default IconButton; diff --git a/packages/frontend/src/design-system/components/common/Layout/Layout.tsx b/packages/frontend/src/design-system/components/common/Layout/Layout.tsx new file mode 100644 index 0000000..4c0a73e --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Layout/Layout.tsx @@ -0,0 +1,19 @@ +import { ReactElement } from "react"; + +import * as layout from "../../../tokens/layout.css"; +import { Header, SideBar } from "../index"; + +interface LayoutProps { + children: ReactElement; +} +export default function Layout({ children }: LayoutProps) { + return ( + <> +
+
+ +
{children}
+
+ + ); +} diff --git a/packages/frontend/src/design-system/components/common/Layout/index.ts b/packages/frontend/src/design-system/components/common/Layout/index.ts new file mode 100644 index 0000000..fc251b1 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Layout/index.ts @@ -0,0 +1,3 @@ +import Layout from "./Layout"; + +export default Layout; diff --git a/packages/frontend/src/design-system/components/common/Modal/Modal.css.ts b/packages/frontend/src/design-system/components/common/Modal/Modal.css.ts new file mode 100644 index 0000000..b5496da --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Modal/Modal.css.ts @@ -0,0 +1,48 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import { + boxShadow, + flexAlignCenter, + flexCenter, + flexColumn, + modalLayer, +} from "../../../tokens/utils.css"; + +export const backdrop = style([ + modalLayer, + flexCenter, + { + position: "fixed", + top: 0, + left: 0, + width: "100vw", + height: "100vh", + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, +]); + +export const container = style([ + boxShadow, + flexColumn, + flexAlignCenter, + { + width: 427, + borderRadius: 8, + padding: 27, + backgroundColor: color.$scale.grey00, + }, +]); + +export const buttonContainer = style({ + width: "100%", + height: 40, + position: "relative", +}); + +export const close = style({ + color: color.$scale.grey900, + position: "absolute", + right: 0, + fontSize: 40, +}); diff --git a/packages/frontend/src/design-system/components/common/Modal/Modal.tsx b/packages/frontend/src/design-system/components/common/Modal/Modal.tsx new file mode 100644 index 0000000..e0b7032 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Modal/Modal.tsx @@ -0,0 +1,65 @@ +import { ReactNode, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { IoCloseOutline } from "react-icons/io5"; + +import { ESC_KEY } from "../../../../constants/event"; +import { preventBubbling } from "../../../../utils/event"; +import IconButton from "../IconButton/IconButton"; + +import * as styles from "./Modal.css"; + +export interface ModalProps { + onClose: () => void; + children: ReactNode; +} + +const setScrollLock = (isLock: boolean) => { + document.body.style.overflow = isLock ? "hidden" : "auto"; +}; + +export default function Modal({ onClose, children }: ModalProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + const escKeyCloseEvent = (event: KeyboardEvent) => { + if (event.key === ESC_KEY) { + onClose(); + } + }; + setScrollLock(true); + window.addEventListener("keydown", escKeyCloseEvent); + return () => { + window.removeEventListener("keydown", escKeyCloseEvent); + setScrollLock(false); + }; + }, [onClose]); + + if (!mounted) return null; + + return createPortal( +
+
+
+ + + +
+ {children} +
+
, + document.getElementById("modal") as HTMLElement, + ); +} diff --git a/packages/frontend/src/design-system/components/common/Modal/index.ts b/packages/frontend/src/design-system/components/common/Modal/index.ts new file mode 100644 index 0000000..02782b1 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Modal/index.ts @@ -0,0 +1,3 @@ +import Modal from "./Modal"; + +export default Modal; diff --git a/packages/frontend/src/design-system/components/common/SideBar/GitHelpAccordian.tsx b/packages/frontend/src/design-system/components/common/SideBar/GitHelpAccordian.tsx new file mode 100644 index 0000000..362a4b6 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/SideBar/GitHelpAccordian.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { BROWSWER_PATH } from "../../../../constants/path"; +import { Accordion } from "../Accordion"; + +import { gitHelpNavigation } from "./nav"; +import * as styles from "./SideBar.css"; + +export default function GitHelpAccordian() { + const { pathname } = useRouter(); + const current = pathname === BROWSWER_PATH.MAIN; + return ( + + + {gitHelpNavigation.title} +
+
+ + {gitHelpNavigation.subTitle} + +
+
+
+
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/SideBar/SideBar.css.ts b/packages/frontend/src/design-system/components/common/SideBar/SideBar.css.ts new file mode 100644 index 0000000..8ed7931 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/SideBar/SideBar.css.ts @@ -0,0 +1,44 @@ +import { style } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; +import typography from "../../../tokens/typography"; +import { border, flexAlignCenter, flexColumn } from "../../../tokens/utils.css"; + +export const linkContainerStyle = style([ + flexColumn, + border.top, + { + paddingTop: 10, + marginTop: 10, + }, +]); + +export const linkItemStyle = style({ + height: 40, + selectors: { + "&:hover": { + borderRadius: 8, + backgroundColor: color.$scale.grey50, + }, + }, +}); + +export const baseLinkStyle = style([ + flexAlignCenter, + typography.$semantic.title4Regular, + { width: "100%", height: "100%", paddingLeft: 15, textDecoration: "none" }, +]); + +export const currentLinkStyle = style([ + baseLinkStyle, + { + color: color.$scale.grey900, + }, +]); + +export const linkStyle = style([ + baseLinkStyle, + { + color: color.$scale.grey600, + }, +]); diff --git a/packages/frontend/src/design-system/components/common/SideBar/SideBar.tsx b/packages/frontend/src/design-system/components/common/SideBar/SideBar.tsx new file mode 100644 index 0000000..ed9a6e8 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/SideBar/SideBar.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { BROWSWER_PATH } from "../../../../constants/path"; +import * as layout from "../../../tokens/layout.css"; +import { Accordion } from "../Accordion"; + +import GitHelpAccordian from "./GitHelpAccordian"; +import { sidebarNavigation } from "./nav"; +import * as styles from "./SideBar.css"; + +export default function SideBar() { + return ( + + ); +} + +interface SubItemsProps { + id?: number; + subTitle: string; +} + +function SubItems({ subItems }: { subItems: SubItemsProps[] }) { + const { + query: { id }, + } = useRouter(); + const idNum = id ? +id : 0; + + return ( +
    + {subItems.map((subTitle) => ( +
  1. + + {subTitle.subTitle} + +
  2. + ))} +
+ ); +} diff --git a/packages/frontend/src/design-system/components/common/SideBar/index.tsx b/packages/frontend/src/design-system/components/common/SideBar/index.tsx new file mode 100644 index 0000000..6af8d8a --- /dev/null +++ b/packages/frontend/src/design-system/components/common/SideBar/index.tsx @@ -0,0 +1,3 @@ +import SideBar from "./SideBar"; + +export default SideBar; diff --git a/packages/frontend/src/design-system/components/common/SideBar/nav.ts b/packages/frontend/src/design-system/components/common/SideBar/nav.ts new file mode 100644 index 0000000..a3c475d --- /dev/null +++ b/packages/frontend/src/design-system/components/common/SideBar/nav.ts @@ -0,0 +1,101 @@ +export const gitHelpNavigation = { + title: "$ Git Help", + subTitle: "명령어 이해하기", +}; + +export const sidebarNavigation = [ + { + id: 1, + title: "Git Start", + subItems: [ + { + id: 1, + subTitle: "git 시작하기", + }, + { + id: 2, + subTitle: "내 정보 설정하기", + }, + { + id: 3, + subTitle: "파일 스테이징 하기", + }, + { + id: 4, + subTitle: "커밋하기", + }, + { + id: 5, + subTitle: "브랜치 만들기", + }, + { + id: 6, + subTitle: "브랜치 바꾸기", + }, + ], + }, + { + id: 2, + title: "Git Advanced", + subItems: [ + { + id: 7, + subTitle: "커밋 메시지 수정하기", + }, + { + id: 8, + subTitle: "커밋 취소하기", + }, + { + id: 9, + subTitle: "파일 되돌리기", + }, + { + id: 10, + subTitle: "파일 삭제하기", + }, + { + id: 11, + subTitle: "변경 사항 저장하기", + }, + { + id: 12, + subTitle: "커밋 가져오기", + }, + { + id: 13, + subTitle: "커밋 이력 조작하기", + }, + { + id: 14, + subTitle: "변경 사항 되돌리기", + }, + ], + }, + { + id: 3, + title: "Remote Start", + subItems: [ + { + id: 15, + subTitle: "원격 저장소 등록하기", + }, + { + id: 16, + subTitle: "브랜치 생성하고 이동하기", + }, + { + id: 17, + subTitle: "브랜치 최신화하기", + }, + { + id: 18, + subTitle: "원격 저장소로 보내기", + }, + { + id: 19, + subTitle: "브랜치 삭제하기", + }, + ], + }, +]; diff --git a/packages/frontend/src/design-system/components/common/Toast/Toast.css.ts b/packages/frontend/src/design-system/components/common/Toast/Toast.css.ts new file mode 100644 index 0000000..1b1bfbb --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Toast/Toast.css.ts @@ -0,0 +1,34 @@ +import { globalStyle } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; + +globalStyle(".Toastify .Toastify__toast", { + display: "block", + width: "max-content", + height: 46, + minHeight: 46, + maxHeight: 46, + marginLeft: "auto", + marginRight: "auto", + borderRadius: 8, + padding: "12px 20px", + textAlign: "center", + color: color.$scale.grey00, + backgroundColor: color.$scale.grey800, +}); + +globalStyle(".Toastify .Toastify__toast-body", { + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: 0, +}); + +globalStyle(".Toastify .Toastify__toast-icon", { + width: 22, + marginRight: 8, +}); + +globalStyle(".Toastify .Toastify__toast-body>div:last-child", { + flex: "0 0 auto", +}); diff --git a/packages/frontend/src/design-system/components/common/Toast/ToastContainer.tsx b/packages/frontend/src/design-system/components/common/Toast/ToastContainer.tsx new file mode 100644 index 0000000..a0721b0 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Toast/ToastContainer.tsx @@ -0,0 +1,21 @@ +import { ToastContainer as BaseToastContainer, Slide } from "react-toastify"; + +import typography from "../../../tokens/typography"; + +import "./Toast.css"; + +export default function ToastContainer() { + return ( + + ); +} diff --git a/packages/frontend/src/design-system/components/common/Toast/ToastIcon.css.ts b/packages/frontend/src/design-system/components/common/Toast/ToastIcon.css.ts new file mode 100644 index 0000000..c84c8c3 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Toast/ToastIcon.css.ts @@ -0,0 +1,18 @@ +import { style, styleVariants } from "@vanilla-extract/css"; + +import color from "../../../tokens/color"; + +export const iconBase = style({ + display: "flex", + justifyContent: "center", + alignItems: "center", + width: 22, + height: 22, + color: color.$semantic.textWhite, + borderRadius: "50%", +}); + +export const iconVariants = styleVariants({ + success: { backgroundColor: color.$semantic.success }, + error: { backgroundColor: color.$semantic.danger }, +}); diff --git a/packages/frontend/src/design-system/components/common/Toast/ToastIcon.tsx b/packages/frontend/src/design-system/components/common/Toast/ToastIcon.tsx new file mode 100644 index 0000000..1aae8b0 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Toast/ToastIcon.tsx @@ -0,0 +1,28 @@ +import { BsX } from "react-icons/bs"; +import { FaCheck } from "react-icons/fa"; + +import classnames from "../../../../utils/classnames"; +import { block } from "../../../tokens/utils.css"; + +import * as styles from "./ToastIcon.css"; + +interface ToastIconProps { + type: "error" | "success"; +} + +export default function ToastIcon({ type }: ToastIconProps) { + const { Icon, size } = iconMap[type]; + return ( +
+ +
+ ); +} + +ToastIcon.error = () => ; +ToastIcon.success = () => ; + +const iconMap = { + error: { Icon: BsX, size: 20 }, + success: { Icon: FaCheck, size: 13 }, +}; diff --git a/packages/frontend/src/design-system/components/common/Toast/index.ts b/packages/frontend/src/design-system/components/common/Toast/index.ts new file mode 100644 index 0000000..cde4302 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Toast/index.ts @@ -0,0 +1,2 @@ +export { default as toast } from "./toast"; +export { default as ToastContainer } from "./ToastContainer"; diff --git a/packages/frontend/src/design-system/components/common/Toast/toast.ts b/packages/frontend/src/design-system/components/common/Toast/toast.ts new file mode 100644 index 0000000..4b1c5d9 --- /dev/null +++ b/packages/frontend/src/design-system/components/common/Toast/toast.ts @@ -0,0 +1,26 @@ +import { + type ToastContent, + type ToastOptions, + toast as baseToast, +} from "react-toastify"; + +import ToastIcon from "./ToastIcon"; + +export default function toast( + content: ToastContent, + options?: ToastOptions +) { + return baseToast(content, options); +} + +toast.success = (content: string, options?: ToastOptions) => + toast(content, { + ...options, + icon: ToastIcon.success, + }); + +toast.error = (content: string, options?: ToastOptions) => + toast(content, { + ...options, + icon: ToastIcon.error, + }); diff --git a/packages/frontend/src/design-system/components/common/index.ts b/packages/frontend/src/design-system/components/common/index.ts new file mode 100644 index 0000000..886886e --- /dev/null +++ b/packages/frontend/src/design-system/components/common/index.ts @@ -0,0 +1,8 @@ +export { default as Header } from "./Header"; +export { default as Button } from "./Button"; +export { default as Modal } from "./Modal"; +export { default as SideBar } from "./SideBar"; +export { Badge, BadgeGroup } from "./Badge"; +export { toast, ToastContainer } from "./Toast"; +export { Accordion, useAccordion } from "./Accordion"; +export { default as Footer } from "./Footer"; diff --git a/packages/frontend/src/design-system/styles/global.css b/packages/frontend/src/design-system/styles/global.css new file mode 100644 index 0000000..ad4c183 --- /dev/null +++ b/packages/frontend/src/design-system/styles/global.css @@ -0,0 +1,297 @@ +@import "reset.css"; +@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css"); +@import url("http://fonts.googleapis.com/earlyaccess/notosanskr.css"); +@import url("https://cdn.jsdelivr.net/npm/firacode@6.2.0/distr/fira_code.css"); + +* { + box-sizing: border-box; +} + +:root[data-theme] { + --mm-static-color-white: #ffffff; + --mm-static-color-grey-200: #eaebee; + --mm-static-color-grey-500: #adb1ba; + --mm-static-color-blue-50: #e8f3ff; + --mm-static-color-blue-700: #1b64da; + --mm-static-color-brown-50: #f4efee; + --mm-static-color-brown-100: #ded5d4; + --mm-static-color-brown-400: #b19792; + --mm-static-color-brown-500: #917974; + --mm-static-color-coral-50: #fff1ed; + --mm-static-color-coral-100: #ffe3dc; + --mm-static-color-coral-400: #ff977d; + --mm-static-color-coral-500: #ff7b5a; + --mm-static-color-green-50: #f0faf6; + --mm-static-color-green-700: #029359; + --mm-static-color-orange-50: #fff3e0; + --mm-static-color-orange-700: #f57800; + --mm-static-color-purple-50: #f9f0fc; + --mm-static-color-purple-700: #8222a2; + --mm-static-color-red-50: #ffeeee; + --mm-static-color-red-700: #d22030; + --mm-static-color-teal-50: #edf8f8; + --mm-static-color-teal-700: #0c8585; + --mm-static-color-yellow-50: #fff9e7; + --mm-static-color-yellow-700: #faa131; + --mm-semantic-color-text-white-default: var(--mm-static-color-white); + --mm-semantic-color-bg-white-default: var(--mm-static-color-white); + --mm-semantic-color-text-disabled: var(--mm-static-color-grey-500); + --mm-semantic-color-bg-disabled: var(--mm-static-color-grey-200); + --mm-semantic-color-badge-blue-bg: var(--mm-static-color-blue-50); + --mm-semantic-color-badge-blue: var(--mm-static-color-blue-700); + --mm-semantic-color-badge-green-bg: var(--mm-static-color-green-50); + --mm-semantic-color-badge-green: var(--mm-static-color-green-700); + --mm-semantic-color-badge-orange-bg: var(--mm-static-color-orange-50); + --mm-semantic-color-badge-orange: var(--mm-static-color-orange-700); + --mm-semantic-color-badge-purple-bg: var(--mm-static-color-purple-50); + --mm-semantic-color-badge-purple: var(--mm-static-color-purple-700); + --mm-semantic-color-badge-red-bg: var(--mm-static-color-red-50); + --mm-semantic-color-badge-red: var(--mm-static-color-red-700); + --mm-semantic-color-badge-teal-bg: var(--mm-static-color-teal-50); + --mm-semantic-color-badge-teal: var(--mm-static-color-teal-700); + --mm-semantic-color-badge-yellow-bg: var(--mm-static-color-yellow-50); + --mm-semantic-color-badge-yellow: var(--mm-static-color-yellow-700); + --mm-semantic-color-primary-hover: var(--mm-static-color-coral-400); + --mm-semantic-color-primary-low-hover: var(--mm-static-color-coral-100); + --mm-semantic-color-primary-low: var(--mm-static-color-coral-50); + --mm-semantic-color-primary: var(--mm-static-color-coral-500); + --mm-semantic-color-secondary-hover: var(--mm-static-color-brown-400); + --mm-semantic-color-secondary-low-hover: var(--mm-static-color-brown-100); + --mm-semantic-color-secondary-low: var(--mm-static-color-brown-50); + --mm-semantic-color-secondary: var(--mm-static-color-brown-500); + --mm-semantic-color-success: var(--mm-static-color-blue-700); + --mm-semantic-color-danger: var(--mm-static-color-red-700); + /* Font Size */ + --mm-scale-font-size-25: 11px; + --mm-scale-font-size-50: 12px; + --mm-scale-font-size-75: 13px; + --mm-scale-font-size-100: 14px; + --mm-scale-font-size-150: 16px; + --mm-scale-font-size-200: 18px; + --mm-scale-font-size-300: 20px; + --mm-scale-font-size-400: 24px; + --mm-scale-font-size-500: 26px; + --mm-scale-font-size-600: 30px; + /* Font Weight */ + --mm-static-font-weight-regular: 400; + --mm-static-font-weight-bold: 700; + /* Line Height */ + --mm-static-line-height-small: 135%; + --mm-static-line-height-medium: 150%; + /* Letter Spacing */ + --mm-static-letter-spacing-none: 0; + --mm-static-letter-spacing-medium: -0.3px; + --mm-static-letter-spacing-large: -0.7px; +} + +:root[data-theme="light"] { + --mm-scale-color-brown-50: #f4efee; + --mm-scale-color-brown-100: #ded5d4; + --mm-scale-color-brown-200: #cfc0be; + --mm-scale-color-brown-300: #c4b0ad; + --mm-scale-color-brown-400: #b19792; + --mm-scale-color-brown-500: #917974; + --mm-scale-color-brown-600: #76605c; + --mm-scale-color-brown-700: #675552; + --mm-scale-color-brown-800: #5a4b48; + --mm-scale-color-brown-900: #4a3c39; + --mm-scale-color-brown-950: #413932; + --mm-scale-color-coral-50: #fff1ed; + --mm-scale-color-coral-100: #ffe3dc; + --mm-scale-color-coral-200: #ffcabc; + --mm-scale-color-coral-300: #ffb4a1; + --mm-scale-color-coral-400: #ff977d; + --mm-scale-color-coral-500: #ff7b5a; + --mm-scale-color-coral-600: #fc6e4a; + --mm-scale-color-coral-700: #f15f3b; + --mm-scale-color-coral-800: #e64f29; + --mm-scale-color-coral-900: #cd4b2b; + --mm-scale-color-coral-950: #a93c20; + --mm-scale-color-grey-00: #ffffff; + --mm-scale-color-grey-50: #f7f8fa; + --mm-scale-color-grey-100: #f2f3f6; + --mm-scale-color-grey-200: #eaebee; + --mm-scale-color-grey-300: #dcdee3; + --mm-scale-color-grey-400: #d1d3d8; + --mm-scale-color-grey-500: #adb1ba; + --mm-scale-color-grey-600: #868b94; + --mm-scale-color-grey-700: #4d5159; + --mm-scale-color-grey-800: #393a40; + --mm-scale-color-grey-900: #212124; + --mm-semantic-color-border: var(--mm-scale-color-grey-300); +} + +:root[data-theme="dark"] { + --mm-scale-color-brown-50: #413932; + --mm-scale-color-brown-100: #4a3c39; + --mm-scale-color-brown-200: #5a4b48; + --mm-scale-color-brown-300: #675552; + --mm-scale-color-brown-400: #76605c; + --mm-scale-color-brown-500: #917974; + --mm-scale-color-brown-600: #b19792; + --mm-scale-color-brown-700: #c4b0ad; + --mm-scale-color-brown-800: #cfc0be; + --mm-scale-color-brown-900: #ded5d4; + --mm-scale-color-brown-950: #f4efee; + --mm-scale-color-coral-50: #a93c20; + --mm-scale-color-coral-100: #cd4b2b; + --mm-scale-color-coral-200: #e64f29; + --mm-scale-color-coral-300: #f15f3b; + --mm-scale-color-coral-400: #fc6e4a; + --mm-scale-color-coral-500: #ff7b5a; + --mm-scale-color-coral-600: #ff977d; + --mm-scale-color-coral-700: #ffb4a1; + --mm-scale-color-coral-800: #ffcabc; + --mm-scale-color-coral-900: #ffe3dc; + --mm-scale-color-coral-950: #fff1ed; + --mm-scale-color-grey-00: #212124; + --mm-scale-color-grey-50: #393a40; + --mm-scale-color-grey-100: #4d5159; + --mm-scale-color-grey-200: #868b94; + --mm-scale-color-grey-300: #adb1ba; + --mm-scale-color-grey-400: #d1d3d8; + --mm-scale-color-grey-500: #dcdee3; + --mm-scale-color-grey-600: #eaebee; + --mm-scale-color-grey-700: #f2f3f6; + --mm-scale-color-grey-800: #f7f8fa; + --mm-scale-color-grey-900: #ffffff; + --mm-semantic-color-border: var(--mm-scale-color-grey-100); +} + +.mm-semantic-typography-h1 { + font-size: var(--mm-scale-font-size-600); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-h2 { + font-size: var(--mm-scale-font-size-500); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-h3 { + font-size: var(--mm-scale-font-size-400); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-title1-regular { + font-size: var(--mm-scale-font-size-300); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-title1-bold { + font-size: var(--mm-scale-font-size-300); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-title2-regular { + font-size: var(--mm-scale-font-size-200); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-title2-bold { + font-size: var(--mm-scale-font-size-200); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-none); +} + +.mm-semantic-typography-title3-regular { + font-size: var(--mm-scale-font-size-150); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-title3-bold { + font-size: var(--mm-scale-font-size-150); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-title4-regular { + font-size: var(--mm-scale-font-size-100); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-title4-bold { + font-size: var(--mm-scale-font-size-100); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-body1-regular { + font-size: var(--mm-scale-font-size-150); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-medium); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-body1-bold { + font-size: var(--mm-scale-font-size-150); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-medium); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-body2-regular { + font-size: var(--mm-scale-font-size-100); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-medium); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-body2-bold { + font-size: var(--mm-scale-font-size-100); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-medium); +} + +.mm-semantic-typography-caption1-regular { + font-size: var(--mm-scale-font-size-75); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-medium); + letter-spacing: var(--mm-static-letter-spacing-large); +} + +.mm-semantic-typography-caption1-bold { + font-size: var(--mm-scale-font-size-75); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-medium); + letter-spacing: var(--mm-static-letter-spacing-large); +} + +.mm-semantic-typography-caption2-regular { + font-size: var(--mm-scale-font-size-50); + font-weight: var(--mm-static-font-weight-regular); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-large); +} + +.mm-semantic-typography-caption2-bold { + font-size: var(--mm-scale-font-size-50); + font-weight: var(--mm-static-font-weight-bold); + line-height: var(--mm-static-line-height-small); + letter-spacing: var(--mm-static-letter-spacing-large); +} + +.mm-semantic-typography-code { + font-family: "Fira Code", monospace; + line-height: var(--mm-static-line-height-medium); +} diff --git a/packages/frontend/src/design-system/styles/reset.css b/packages/frontend/src/design-system/styles/reset.css new file mode 100644 index 0000000..61972a0 --- /dev/null +++ b/packages/frontend/src/design-system/styles/reset.css @@ -0,0 +1,132 @@ +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} +body { + line-height: 1; + font-family: "Pretendard Variable", "Noto Sans KR", sans-serif; + font-weight: 400; + background-color: var(--mm-scale-color-grey-00); +} + +ol, +ul { + list-style: none; +} +blockquote, +q { + quotes: none; +} +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ""; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + +button { + cursor: pointer; +} diff --git a/packages/frontend/src/design-system/tokens/color.ts b/packages/frontend/src/design-system/tokens/color.ts new file mode 100644 index 0000000..a19d1b2 --- /dev/null +++ b/packages/frontend/src/design-system/tokens/color.ts @@ -0,0 +1,70 @@ +const $scale = { + grey00: "var(--mm-scale-color-grey-00)", + grey50: "var(--mm-scale-color-grey-50)", + grey100: "var(--mm-scale-color-grey-100)", + grey200: "var(--mm-scale-color-grey-200)", + grey300: "var(--mm-scale-color-grey-300)", + grey400: "var(--mm-scale-color-grey-400)", + grey500: "var(--mm-scale-color-grey-500)", + grey600: "var(--mm-scale-color-grey-600)", + grey700: "var(--mm-scale-color-grey-700)", + grey800: "var(--mm-scale-color-grey-800)", + grey900: "var(--mm-scale-color-grey-900)", + coral50: "var(--mm-scale-color-coral-50)", + coral100: "var(--mm-scale-color-coral-100)", + coral200: "var(--mm-scale-color-coral-200)", + coral300: "var(--mm-scale-color-coral-300)", + coral400: "var(--mm-scale-color-coral-400)", + coral500: "var(--mm-scale-color-coral-500)", + coral600: "var(--mm-scale-color-coral-600)", + coral700: "var(--mm-scale-color-coral-700)", + coral800: "var(--mm-scale-color-coral-800)", + coral900: "var(--mm-scale-color-coral-900)", + coral950: "var(--mm-scale-color-coral-950)", + brown50: "var(--mm-scale-color-brown-50)", + brown100: "var(--mm-scale-color-brown-100)", + brown200: "var(--mm-scale-color-brown-200)", + brown300: "var(--mm-scale-color-brown-300)", + brown400: "var(--mm-scale-color-brown-400)", + brown500: "var(--mm-scale-color-brown-500)", + brown600: "var(--mm-scale-color-brown-600)", + brown700: "var(--mm-scale-color-brown-700)", + brown800: "var(--mm-scale-color-brown-800)", + brown900: "var(--mm-scale-color-brown-900)", + brown950: "var(--mm-scale-color-brown-950)", +}; + +const $semantic = { + textWhite: "var(--mm-semantic-color-text-white-default)", + bgWhite: "var(--mm-semantic-color-bg-white-default)", + textDisabled: "var(--mm-semantic-color-text-disabled)", + bgDisabled: "var(--mm-semantic-color-bg-disabled)", + primary: "var(--mm-semantic-color-primary)", + primaryHover: "var(--mm-semantic-color-primary-hover)", + primaryLow: "var(--mm-semantic-color-primary-low)", + primaryLowHover: "var(--mm-semantic-color-primary-low-hover)", + secondary: "var(--mm-semantic-color-secondary)", + secondaryHover: "var(--mm-semantic-color-secondary-hover)", + secondaryLow: "var(--mm-semantic-color-secondary-low)", + secondaryLowHover: "var(--mm-semantic-color-secondary-low-hover)", + border: "var(--mm-semantic-color-border)", + success: "var(--mm-semantic-color-success)", + danger: "var(--mm-semantic-color-danger)", + badgeBlue: "var(--mm-semantic-color-badge-blue)", + badgeBlueBg: "var(--mm-semantic-color-badge-blue-bg)", + badgeGreen: "var(--mm-semantic-color-badge-green)", + badgeGreenBg: "var(--mm-semantic-color-badge-green-bg)", + badgeOrange: "var(--mm-semantic-color-badge-orange)", + badgeOrangeBg: "var(--mm-semantic-color-badge-orange-bg)", + badgePurple: "var(--mm-semantic-color-badge-purple)", + badgePurpleBg: "var(--mm-semantic-color-badge-purple-bg)", + badgeRed: "var(--mm-semantic-color-badge-red)", + badgeRedBg: "var(--mm-semantic-color-badge-red-bg)", + badgeTeal: "var(--mm-semantic-color-badge-teal)", + badgeTealBg: "var(--mm-semantic-color-badge-teal-bg)", + badgeYellow: "var(--mm-semantic-color-badge-yellow)", + badgeYellowBg: "var(--mm-semantic-color-badge-yellow-bg)", +}; + +const color = { $scale, $semantic }; +export default color; diff --git a/packages/frontend/src/design-system/tokens/layout.css.ts b/packages/frontend/src/design-system/tokens/layout.css.ts new file mode 100644 index 0000000..db569ec --- /dev/null +++ b/packages/frontend/src/design-system/tokens/layout.css.ts @@ -0,0 +1,57 @@ +import { style } from "@vanilla-extract/css"; + +import { + flex, + flexColumn, + middleLayer, + scrollBarHidden, + widthFull, + widthMax, +} from "./utils.css"; + +const headerHeight = "56px"; +const footerHeight = "250px"; + +export const header = style([ + middleLayer, + widthFull, + { + height: headerHeight, + position: "fixed", + top: 0, + left: 0, + }, +]); + +export const base = style([ + flex, + widthMax, + { + height: "100vh", + paddingTop: headerHeight, + margin: "0 auto", + }, +]); + +export const sideBar = style([ + flexColumn, + scrollBarHidden, + { + width: 250, + padding: "30px 0px", + gap: 24, + }, +]); + +export const container = style({ + width: 1030, +}); + +export const footer = style([ + widthMax, + { + height: footerHeight, + margin: "0 auto", + padding: "45px 0", + }, +]); diff --git a/packages/frontend/src/design-system/tokens/typography.ts b/packages/frontend/src/design-system/tokens/typography.ts new file mode 100644 index 0000000..4150e60 --- /dev/null +++ b/packages/frontend/src/design-system/tokens/typography.ts @@ -0,0 +1,25 @@ +const $semantic = { + h1: "mm-semantic-typography-h1", + h2: "mm-semantic-typography-h2", + h3: "mm-semantic-typography-h3", + title1Regular: "mm-semantic-typography-title1-regular", + title1Bold: "mm-semantic-typography-title1-bold", + title2Regular: "mm-semantic-typography-title2-regular", + title2Bold: "mm-semantic-typography-title2-bold", + title3Regular: "mm-semantic-typography-title3-regular", + title3Bold: "mm-semantic-typography-title3-bold", + title4Regular: "mm-semantic-typography-title4-regular", + title4Bold: "mm-semantic-typography-title4-bold", + body1Regular: "mm-semantic-typography-body1-regular", + body1Bold: "mm-semantic-typography-body1-bold", + body2Regular: "mm-semantic-typography-body2-regular", + body2Bold: "mm-semantic-typography-body2-bold", + caption1Regular: "mm-semantic-typography-caption1-regular", + caption1Bold: "mm-semantic-typography-caption1-bold", + caption2Regular: "mm-semantic-typography-caption2-regular", + caption2Bold: "mm-semantic-typography-caption2-bold", + code: "mm-semantic-typography-code", +}; + +const typography = { $semantic }; +export default typography; diff --git a/packages/frontend/src/design-system/tokens/utils.css.ts b/packages/frontend/src/design-system/tokens/utils.css.ts new file mode 100644 index 0000000..867b86a --- /dev/null +++ b/packages/frontend/src/design-system/tokens/utils.css.ts @@ -0,0 +1,73 @@ +import { style } from "@vanilla-extract/css"; + +import color from "./color"; + +export const widthMax = style({ maxWidth: 1280 }); +export const widthFull = style({ width: "100%" }); +export const backLayer = style({ zIndex: -1 }); +export const baseLayer = style({ zIndex: 0 }); +export const middleLayer = style({ zIndex: 50 }); +export const topLayer = style({ zIndex: 100 }); +export const modalLayer = style({ zIndex: 1000 }); +export const block = style({ + display: "block", +}); +export const flex = style({ + display: "flex", +}); +export const flexColumn = style([ + flex, + { + flexDirection: "column", + }, +]); +export const flexAlignCenter = style([ + flex, + { + alignItems: "center", + }, +]); +export const flexJustifyCenter = style([ + flex, + { + justifyContent: "center", + }, +]); +export const flexCenter = style([flex, flexAlignCenter, flexJustifyCenter]); +export const flexColumnCenter = style([ + flexCenter, + { + flexDirection: "column", + }, +]); + +export const boxShadow = style({ + boxShadow: "0 3px 10px rgba(0,0,0,0.1), 0 3px 3px rgba(0,0,0,0.05)", +}); + +export const scrollBarHidden = style({ + overflow: "scroll", + msOverflowStyle: "none", + scrollbarWidth: "none", + "::-webkit-scrollbar": { + display: "none", + }, +}); + +export const border = { + top: style({ borderTop: `1px solid ${color.$semantic.border}` }), + bottom: style({ borderBottom: `1px solid ${color.$semantic.border}` }), + verticalSide: style({ + border: `1px solid ${color.$semantic.border}`, + borderTop: "none", + borderBottom: "none", + }), + horizontalSide: style({ + border: `1px solid ${color.$semantic.border}`, + borderLeft: "none", + borderRight: "none", + }), + all: style({ + border: `1px solid ${color.$semantic.border}`, + }), +}; diff --git a/packages/frontend/src/hooks/useModal.ts b/packages/frontend/src/hooks/useModal.ts new file mode 100644 index 0000000..51b373b --- /dev/null +++ b/packages/frontend/src/hooks/useModal.ts @@ -0,0 +1,14 @@ +import { useState } from "react"; + +export default function useModal() { + const [modalOpen, setModalOpen] = useState(false); + const closeModal = () => { + setModalOpen(false); + }; + + const openModal = () => { + setModalOpen(true); + }; + + return { modalOpen, openModal, closeModal }; +} diff --git a/packages/frontend/src/mocks/apis/data/quizContentData.ts b/packages/frontend/src/mocks/apis/data/quizContentData.ts new file mode 100644 index 0000000..cc6e984 --- /dev/null +++ b/packages/frontend/src/mocks/apis/data/quizContentData.ts @@ -0,0 +1,9 @@ +const quizContentMockData = { + id: 3, + title: "git add & git status", + description: `현재 디렉터리의 Git 저장소 환경에서 user name과 user email을 여러분의 name과 email로 설정해주세요.`, + keywords: ["add", "status"], + category: "Git Start", +}; + +export default quizContentMockData; diff --git a/packages/frontend/src/pages/_app.tsx b/packages/frontend/src/pages/_app.tsx new file mode 100644 index 0000000..c7bb1eb --- /dev/null +++ b/packages/frontend/src/pages/_app.tsx @@ -0,0 +1,23 @@ +import "../design-system/styles/global.css"; + +import type { AppProps } from "next/app"; +import React from "react"; + +import "react-toastify/dist/ReactToastify.min.css"; + +import { ToastContainer } from "../design-system/components/common"; +import Layout from "../design-system/components/common/Layout"; + +export default function App({ Component, pageProps }: AppProps) { + return ( + <> + + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + + + + ); +} diff --git a/packages/frontend/src/pages/_document.tsx b/packages/frontend/src/pages/_document.tsx new file mode 100644 index 0000000..54b038a --- /dev/null +++ b/packages/frontend/src/pages/_document.tsx @@ -0,0 +1,14 @@ +import { Head, Html, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + + + +
+