├── .github └── workflows │ ├── backend-deploy.yml │ ├── backend.yml │ ├── client-deploy.yml │ └── iOS.yml ├── .gitignore ├── README.md ├── backend ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── deploy.sh ├── package-lock.json ├── package.json └── src │ ├── app.js │ ├── config │ └── index.js │ ├── controller │ ├── __test__ │ │ ├── comment.test.js │ │ ├── image.test.js │ │ ├── issue.test.js │ │ ├── label.test.js │ │ ├── milestone.test.js │ │ └── user.test.js │ ├── comment.js │ ├── image.js │ ├── issue.js │ ├── label.js │ ├── milestone.js │ └── user.js │ ├── lib │ ├── controller.js │ ├── store.js │ └── utils │ │ ├── jwt.js │ │ └── multer.js │ ├── model │ ├── __test__ │ │ ├── comment.test.js │ │ ├── issue.test.js │ │ ├── label.test.js │ │ ├── milestone.test.js │ │ └── user.test.js │ ├── comment.js │ ├── index.js │ ├── issue.js │ ├── label.js │ ├── milestone.js │ └── user.js │ ├── passport │ ├── index.js │ └── jwt.js │ ├── routes │ ├── comment.js │ ├── image.js │ ├── index.js │ ├── issue.js │ ├── label.js │ ├── middleware │ │ ├── git-auth.js │ │ └── jwt-auth.js │ ├── milestone.js │ └── user.js │ └── service │ ├── __test__ │ ├── comment.test.js │ ├── image.test.js │ ├── issue.test.js │ ├── label.test.js │ └── milestone.test.js │ ├── comment.js │ ├── image.js │ ├── issue.js │ ├── label.js │ ├── milestone.js │ └── user.js ├── client ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── deploy.sh ├── jest.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.jsx │ ├── apis │ │ ├── comment.js │ │ ├── issue.js │ │ ├── label.js │ │ ├── milestone.js │ │ └── user.js │ ├── components │ │ ├── Dropdown │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── DropdownItem │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── EditLabel │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── IssueItem │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── IssueList │ │ │ ├── dummy.js │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── LabelMileNavForm │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── Layout │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── MilestoneBox │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── NewComment │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── Overlay │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── SelectMenu │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ └── Sidebar │ │ │ ├── Assignee │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ │ ├── Label │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ │ ├── Milestone │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ │ ├── index.jsx │ │ │ └── styled.js │ ├── index.js │ ├── lib │ │ ├── PrivateRoute.jsx │ │ ├── axios.js │ │ ├── calculate-time.js │ │ └── make-search.js │ ├── pages │ │ ├── Callback │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── IssueDetail │ │ │ ├── Comment │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── EditComment │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── IssueComment │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── IssueHeader │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── IssueNew │ │ │ ├── IssueNew.test.js │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── Issues │ │ │ ├── IssueItem │ │ │ │ ├── IssueItem.test.js │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── IssueList │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── ListHeader │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── MarkAs │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── ResetFilter │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── SearchBox │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ └── index.jsx │ │ ├── Labels │ │ │ ├── LabelItem │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── LabelList │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ └── index.jsx │ │ ├── Login │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── MilestoneEdit │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── MilestoneNew │ │ │ ├── index.jsx │ │ │ └── styled.js │ │ ├── Milestones │ │ │ ├── MilestoneItem │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── MilestoneList │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ ├── MilestoneListHeader │ │ │ │ ├── index.jsx │ │ │ │ └── styled.js │ │ │ └── index.jsx │ │ └── index.js │ ├── setUpTest.js │ ├── stores │ │ ├── createInfoStore.js │ │ ├── issueStore.js │ │ └── userStore.js │ └── style │ │ ├── global-style.js │ │ └── theme.js └── webpack.config.js └── iOS └── IssueTracker ├── .swiftlint.yml ├── IssueTracker.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── seungeonkim.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcshareddata │ └── xcschemes │ └── IssueTracker.xcscheme ├── IssueTracker.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── IssueTracker ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-1024.png │ │ ├── Icon-120.png │ │ ├── Icon-121.png │ │ ├── Icon-152.png │ │ ├── Icon-167.png │ │ ├── Icon-180.png │ │ ├── Icon-20.png │ │ ├── Icon-29.png │ │ ├── Icon-40.png │ │ ├── Icon-41.png │ │ ├── Icon-42.png │ │ ├── Icon-58.png │ │ ├── Icon-59.png │ │ ├── Icon-60.png │ │ ├── Icon-76.png │ │ ├── Icon-80.png │ │ ├── Icon-81.png │ │ └── Icon-87.png │ ├── Contents.json │ ├── github-icon.dataset │ │ ├── Contents.json │ │ └── github-icon.gif │ └── github.imageset │ │ ├── Contents.json │ │ ├── github_icon-1.png │ │ ├── github_icon@2x.png │ │ └── github_icon@3x.png ├── Coordinators │ ├── Coordinator.swift │ └── Roots │ │ ├── Main │ │ ├── Issue │ │ │ ├── Filter │ │ │ │ └── FilterCoordinator.swift │ │ │ ├── IssueCoordinator.swift │ │ │ └── IssueDetailCoordinator.swift │ │ ├── Label │ │ │ └── LabelCoordinator.swift │ │ ├── MainTabBarCoordinator.swift │ │ └── Milestone │ │ │ └── MilestoneCoordinator.swift │ │ ├── RootCoordinateController.swift │ │ └── Sign │ │ └── SignCoordinator.swift ├── Extensions │ ├── DataRequest+responseBool.swift │ ├── UIColor+hexString.swift │ ├── UIImage+gif.swift │ └── UIImageView+URL.swift ├── Info.plist ├── IssueTracker.entitlements ├── Models │ ├── Assignees.swift │ ├── Comment.swift │ ├── Filter │ │ ├── Filter.swift │ │ └── FilterContext.swift │ ├── Issue.swift │ ├── Label.swift │ ├── Milestone.swift │ ├── Model.swift │ └── User.swift ├── Networks │ ├── AssigneeNetworkService.swift │ ├── CommentNetworkService.swift │ ├── ImageNetworkService.swift │ ├── IssueNetworkService.swift │ ├── LabelNetworkService.swift │ ├── MilestoneNetworkService.swift │ ├── NetworkService.swift │ └── UserNetworkService.swift ├── SceneDelegate.swift ├── ViewControllers │ ├── Filter │ │ ├── FilterSearchViewController.swift │ │ ├── FilterViewController.swift │ │ └── SearchController.swift │ ├── Issue │ │ ├── Detail │ │ │ ├── Bottom │ │ │ │ ├── IssueBottomSheetViewController+PanGesture.swift │ │ │ │ ├── IssueBottomSheetViewController.swift │ │ │ │ ├── IssueEditCacheService.swift │ │ │ │ └── IssueEditService.swift │ │ │ ├── EditItems.swift │ │ │ ├── IssueCommentViewController.swift │ │ │ ├── IssueDetailCacheService.swift │ │ │ ├── IssueDetailService.swift │ │ │ ├── IssueDetailViewController.swift │ │ │ ├── IssueEditController.swift │ │ │ └── IssueEditViewController.swift │ │ ├── EditableTextViewDelegate.swift │ │ ├── IssueAppendViewController.swift │ │ ├── IssueCacheService.swift │ │ ├── IssueService.swift │ │ └── IssueViewController.swift │ ├── LabelAppendViewController.swift │ ├── LabelViewController.swift │ ├── Milestone │ │ ├── MilestoneAppendViewController.swift │ │ ├── MilestoneCacheService.swift │ │ ├── MilestoneService.swift │ │ └── MilestoneViewController.swift │ └── Sign │ │ ├── AppleAuthorizationController.swift │ │ ├── GithubSignController.swift │ │ ├── GithubSignService.swift │ │ └── SignViewController.swift ├── Views │ ├── Customs │ │ ├── BottomSheet │ │ │ └── Cells │ │ │ │ ├── AssigneeCell.swift │ │ │ │ ├── IssueSectionHeader.swift │ │ │ │ ├── LabelCell.swift │ │ │ │ └── MilestoneCell.swift │ │ ├── ElementView.swift │ │ ├── Issue │ │ │ ├── BarButtonController.swift │ │ │ ├── CheckBox.swift │ │ │ ├── EditItemFactory.swift │ │ │ ├── IssueDetailContentCollectionViewCell.swift │ │ │ ├── IssueTableView.swift │ │ │ ├── IssueTableViewCell.swift │ │ │ └── SelectButton.swift │ │ ├── IssueDetail │ │ │ ├── IssueCommentCollectionViewCell.swift │ │ │ ├── IssueContentCollectionViewCell.swift │ │ │ └── IssueDetailCollectionViewCell.swift │ │ ├── Milestone │ │ │ └── MilestoneCollectionViewCell.swift │ │ ├── RoundButton.swift │ │ ├── ShadowView.swift │ │ └── label │ │ │ └── LabelCollectionViewCell.swift │ ├── Designables │ │ └── BadgeView.swift │ ├── Storyboards │ │ ├── Comment.storyboard │ │ ├── Filter.storyboard │ │ ├── Issue.storyboard │ │ ├── IssueAppend.storyboard │ │ ├── IssueBottomSheet.storyboard │ │ ├── IssueDetail.storyboard │ │ ├── IssueEdit.storyboard │ │ ├── Label.storyboard │ │ ├── LabelAppend.storyboard │ │ ├── LaunchScreen.storyboard │ │ ├── Milestone.storyboard │ │ ├── MilestoneAppend.storyboard │ │ ├── Search.storyboard │ │ └── SignIn.storyboard │ └── Xibs │ │ ├── InputField.swift │ │ └── InputField.xib └── utils │ ├── AlertControllerFactory.swift │ ├── PersistenceManager.swift │ └── UIStackView+clear.swift ├── IssueTrackerTests ├── ColorTests.swift ├── Info.plist └── NetworkService │ ├── AssigneeNetworkServiceTests.swift │ ├── CommentNetworkServiceTests.swift │ ├── ImageNetworkServiceTests.swift │ ├── IssueNetworkServiceTests.swift │ ├── LabelNetworkServiceTests.swift │ ├── MilestoneNetworkServiceTests.swift │ └── UserNetworkServiceTests.swift ├── IssueTrackerUITests ├── Info.plist └── IssueTrackerUITests.swift ├── Podfile └── Storyboards └── Base.lproj ├── LaunchScreen.storyboard └── Main.storyboard /.github/workflows/backend-deploy.yml: -------------------------------------------------------------------------------- 1 | name: backend-deploy 2 | 3 | on: 4 | push: 5 | branches: [BE/dev] 6 | 7 | jobs: 8 | build: 9 | runs-on: [ubuntu-latest] 10 | steps: 11 | - name: executing remote ssh commands using password 12 | uses: appleboy/ssh-action@master 13 | with: 14 | host: ${{ secrets.BE_HOST }} 15 | username: ${{ secrets.BE_USERNAME }} 16 | password: ${{ secrets.BE_PASSWORD }} 17 | script: | 18 | bash deploy.sh 19 | -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: backend 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | pull_request: 9 | branches: [BE/dev] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: [ubuntu-latest] 17 | 18 | strategy: 19 | matrix: 20 | node-version: [12.x] 21 | defaults: 22 | run: 23 | working-directory: ./backend 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 28 | - uses: actions/checkout@v2 29 | 30 | - name: npm install 31 | run: npm install 32 | - name: run test 33 | run: npm run test 34 | env: 35 | PORT: 3000 36 | DB_NAME: ${{ secrets.DB_NAME }} 37 | DB_HOST: ${{ secrets.DB_HOST }} 38 | DB_USER: ${{ secrets.DB_USER }} 39 | DB_PASSWORD: ${{ secrets.DB_PASSWORD }} 40 | DB_PORT: 3306 41 | DB_DIALECT: mysql 42 | GITHUB_ID: test 43 | GITHUB_SECRET: test 44 | JWT_SECRET: test 45 | SERVER_URL: test 46 | -------------------------------------------------------------------------------- /.github/workflows/client-deploy.yml: -------------------------------------------------------------------------------- 1 | name: frontend 2 | 3 | on: 4 | push: 5 | branches: [FE/dev] 6 | 7 | jobs: 8 | build: 9 | runs-on: [ubuntu-latest] 10 | steps: 11 | - name: executing remote ssh commands using password 12 | uses: appleboy/ssh-action@master 13 | with: 14 | host: ${{ secrets.HOST }} 15 | username: ${{ secrets.USERNAME }} 16 | password: ${{ secrets.PASSWORD }} 17 | script: | 18 | bash deploy.sh 19 | -------------------------------------------------------------------------------- /.github/workflows/iOS.yml: -------------------------------------------------------------------------------- 1 | name: iOS 2 | 3 | on: 4 | pull_request: 5 | branches: [ iOS ] 6 | 7 | jobs: 8 | build: 9 | runs-on: macOS-latest 10 | env: 11 | XC_VERSION: ${{ '12.1' }} 12 | XC_WORKSPACE: ${{ 'IssueTracker.xcworkspace' }} 13 | XC_SCHEME: ${{ 'IssueTracker' }} 14 | 15 | steps: 16 | - name: Select Xcode Version 17 | run: "sudo xcode-select -s /Applications/Xcode_$XC_VERSION.app" 18 | 19 | - uses: actions/checkout@v2 20 | 21 | - name: Install Dependency 22 | working-directory: ./iOS/IssueTracker 23 | run: "pod install --repo-update --clean-install" 24 | 25 | - name: Run Unit and UI Tests 26 | working-directory: ./iOS/IssueTracker 27 | run: /usr/bin/xcodebuild test -workspace "$XC_WORKSPACE" -scheme "$XC_SCHEME" -destination 'platform=iOS Simulator,name=iPhone 12' 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/macos,xcode,cocoapods 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode,cocoapods 4 | 5 | ### CocoaPods ### 6 | ## CocoaPods GitIgnore Template 7 | 8 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 9 | # - Also handy if you have a large number of dependant pods 10 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 11 | Pods/ 12 | 13 | ### macOS ### 14 | # General 15 | .DS_Store 16 | .AppleDouble 17 | .LSOverride 18 | 19 | # Icon must end with two \r 20 | Icon 21 | 22 | 23 | # Thumbnails 24 | ._* 25 | 26 | # Files that might appear in the root of a volume 27 | .DocumentRevisions-V100 28 | .fseventsd 29 | .Spotlight-V100 30 | .TemporaryItems 31 | .Trashes 32 | .VolumeIcon.icns 33 | .com.apple.timemachine.donotpresent 34 | 35 | # Directories potentially created on remote AFP share 36 | .AppleDB 37 | .AppleDesktop 38 | Network Trash Folder 39 | Temporary Items 40 | .apdisk 41 | 42 | ### Xcode ### 43 | # Xcode 44 | # 45 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 46 | 47 | ## User settings 48 | xcuserdata/ 49 | 50 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 51 | *.xcscmblueprint 52 | *.xccheckout 53 | 54 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 55 | build/ 56 | DerivedData/ 57 | *.moved-aside 58 | *.pbxuser 59 | !default.pbxuser 60 | *.mode1v3 61 | !default.mode1v3 62 | *.mode2v3 63 | !default.mode2v3 64 | *.perspectivev3 65 | !default.perspectivev3 66 | 67 | ## Gcc Patch 68 | /*.gcno 69 | 70 | ### Xcode Patch ### 71 | *.xcodeproj/* 72 | !*.xcodeproj/project.pbxproj 73 | !*.xcodeproj/xcshareddata/ 74 | !*.xcworkspace/contents.xcworkspacedata 75 | **/xcshareddata/WorkspaceSettings.xcsettings 76 | 77 | # End of https://www.toptal.com/developers/gitignore/api/macos,xcode,cocoapods 78 | iOS/IssueTracker/Podfile.lock 79 | -------------------------------------------------------------------------------- /backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // 코드 포맷을 prettier로 설정 3 | "plugins": ["prettier"], 4 | 5 | // eslint의 룰을 기본 권장설정으로 설정 6 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 7 | 8 | // 코드를 해석하는 parser에 대한 설정 9 | "parserOptions": { 10 | // 자바스크립트 버전, 7은 ECMA2016 11 | "ecmaVersion": 2018, 12 | // 모듈 export를 위해 import, export를 사용 가능여부를 설정, script는 사용불가 13 | "sourceType": "script", 14 | // jsx 허용을 설정, back-end 설정이기 때문에 사용 안함 15 | "ecmaFeatures": { 16 | "jsx": false 17 | } 18 | }, 19 | 20 | // linter가 파일을 분석할 때, 미리 정의된 전역변수에 무엇이 있는지 명시하는 속성 21 | "env": { 22 | // 브라우저의 document와 같은 객체 사용 여부 23 | "browser": false, 24 | // node.js에서 console과 같은 전역변수 사용 여부 25 | "node": true 26 | }, 27 | // ESLint가 무시할 디렉토리, 파일을 설정 28 | "ignorePatterns": ["node_modules/"], 29 | 30 | // ESLint 룰을 설정 31 | "rules": { 32 | // prettier에 맞게 룰을 설정 33 | "prettier/prettier": ["error",{ 34 | "endOfLine": "auto" 35 | }] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "arrowParens": "always", 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ## Directory Structure 2 | 3 | ``` 4 | src 5 | +-- config 6 | +-- controller 7 | +-- lib 8 | +-- utils 9 | +-- model 10 | +-- passport 11 | +-- routes 12 | +-- middleware 13 | +-- service 14 | ``` 15 | -------------------------------------------------------------------------------- /backend/deploy.sh: -------------------------------------------------------------------------------- 1 | cd issueTracker/IssueTracker-09/backend 2 | git fetch upstream BE/dev 3 | git rebase upstream/BE/dev 4 | npm install 5 | pm2 restart 16 -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/app.js", 6 | "scripts": { 7 | "dev": "nodemon app.js", 8 | "test": "jest" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "babel-eslint": "^10.1.0", 15 | "eslint": "^7.12.0", 16 | "eslint-config-prettier": "^6.14.0", 17 | "eslint-plugin-prettier": "^3.1.4", 18 | "jest": "^26.6.1", 19 | "nodemon": "^2.0.6", 20 | "prettier": "^2.1.2" 21 | }, 22 | "dependencies": { 23 | "axios": "^0.21.0", 24 | "cookie-parser": "^1.4.5", 25 | "cors": "^2.8.5", 26 | "dotenv": "^8.2.0", 27 | "express": "^4.17.1", 28 | "jsonwebtoken": "^8.5.1", 29 | "morgan": "^1.10.0", 30 | "multer": "^1.4.2", 31 | "mysql2": "^2.2.5", 32 | "node-mocks-http": "^1.9.0", 33 | "passport": "^0.4.1", 34 | "passport-apple": "^1.1.1", 35 | "passport-jwt": "^4.0.0", 36 | "sequelize": "^6.3.5", 37 | "sequelize-mock": "^0.10.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/app.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | const morgan = require('morgan'); 4 | const passport = require('passport'); 5 | const passportStrategy = require('./passport'); 6 | const sequelize = require('./model').sequelize; 7 | const path = require('path'); 8 | const cors = require('cors'); 9 | const cookieParser = require('cookie-parser'); 10 | 11 | // Router 12 | const indexRouter = require('./routes'); 13 | 14 | // Config 15 | const { config } = require('./config'); 16 | 17 | const app = express(); 18 | 19 | app.use(express.json()); 20 | app.use(express.urlencoded({ extended: false })); 21 | app.use(cookieParser()); 22 | app.use(express.static('uploads')); 23 | app.use(morgan('dev')); 24 | app.use(passport.initialize()); 25 | app.use( 26 | cors({ 27 | origin: true, 28 | credentials: true, 29 | }) 30 | ); 31 | passportStrategy(); 32 | 33 | app.use(express.static(path.join(__dirname, '../uploads/'))); 34 | app.use('/api', indexRouter); 35 | 36 | sequelize.sync().then(() => { 37 | console.log(`db connected`); 38 | app.listen(config.port, () => { 39 | console.log(`server is running on ${config.port} port`); 40 | }); 41 | }); 42 | 43 | module.exports = app; 44 | -------------------------------------------------------------------------------- /backend/src/config/index.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | 3 | dotenv.config(); 4 | 5 | exports.config = { 6 | port: process.env.PORT, 7 | }; 8 | 9 | exports.dbConfig = { 10 | database: process.env.DB_NAME, 11 | username: process.env.DB_USER, 12 | password: process.env.DB_PASSWORD, 13 | host: process.env.DB_HOST, 14 | dialect: process.env.DB_DIALECT, 15 | port: process.env.DB_PORT, 16 | define: { 17 | freezeTableName: true, 18 | charset: 'utf8', 19 | collate: 'utf8_general_ci', 20 | timestamps: false, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /backend/src/controller/__test__/image.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const imageController = require('../image'); 3 | const imageService = require('../../service/image'); 4 | 5 | const httpMocks = require('node-mocks-http'); 6 | 7 | imageService.upload = jest.fn(); 8 | 9 | let req, res; 10 | 11 | beforeEach(() => { 12 | req = httpMocks.createRequest(); 13 | res = httpMocks.createResponse(); 14 | }); 15 | 16 | describe('upload image Controller 테스트', () => { 17 | const resulted = { imageURL: 'imageurl.jpg' }; 18 | beforeEach(() => { 19 | req.file = resulted; 20 | }); 21 | 22 | it('함수인가', () => { 23 | imageService.upload.mockReturnValue(resulted); 24 | expect(typeof imageController.upload).toBe('function'); 25 | }); 26 | 27 | it('service에 newimage가 들어가는가', async () => { 28 | imageService.upload.mockReturnValue(resulted); 29 | await imageController.upload(req, res); 30 | expect(imageService.upload).toBeCalledWith(resulted); 31 | }); 32 | 33 | it('성공 시 201응답이 오는가', async () => { 34 | imageService.upload.mockReturnValue(resulted); 35 | await imageController.upload(req, res); 36 | expect(res.statusCode).toBe(201); 37 | expect(res._isEndCalled()).toBeTruthy(); 38 | }); 39 | 40 | it('json을 리턴하는가', async () => { 41 | imageService.upload.mockReturnValue(resulted); 42 | await imageController.upload(req, res); 43 | expect(res._isJSON()).toBeTruthy(); 44 | }); 45 | 46 | it('에러가 나면 400응답이 오는가', async () => { 47 | const errorMessage = { error: 'Error Message' }; 48 | imageService.upload.mockReturnValue(errorMessage); 49 | await imageController.upload(req, res); 50 | expect(res.statusCode).toBe(400); 51 | expect(res._isEndCalled()).toBeTruthy(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /backend/src/controller/comment.js: -------------------------------------------------------------------------------- 1 | const commentService = require('../service/comment'); 2 | const control = require('../lib/controller'); 3 | 4 | module.exports = { 5 | create: async (req, res) => { 6 | const { status, result } = await control( 7 | commentService.create, 8 | { ...req.body, userId: req.user.id }, 9 | 201 10 | ); 11 | 12 | return res.status(status).json(result); 13 | }, 14 | 15 | read: async (req, res) => { 16 | const { status, result } = await control(commentService.read, req.query); 17 | 18 | return res.status(status).json(result); 19 | }, 20 | 21 | readById: async (req, res) => { 22 | const { status, result } = await control( 23 | commentService.readById, 24 | req.params 25 | ); 26 | 27 | return res.status(status).json(result); 28 | }, 29 | 30 | remove: async (req, res) => { 31 | const { status, result } = await control(commentService.remove, req.params); 32 | 33 | return res.status(status).json(result); 34 | }, 35 | 36 | update: async (req, res) => { 37 | const { status, result } = await control(commentService.update, { 38 | ...req.body, 39 | ...req.params, 40 | }); 41 | 42 | return res.status(status).json(result); 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /backend/src/controller/image.js: -------------------------------------------------------------------------------- 1 | const imageService = require('../service/image'); 2 | const control = require('../lib/controller'); 3 | 4 | module.exports = { 5 | upload: async (req, res) => { 6 | const { status, result } = await control( 7 | imageService.upload, 8 | req.file, 9 | 201 10 | ); 11 | return res.status(status).json(result); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /backend/src/controller/label.js: -------------------------------------------------------------------------------- 1 | const labelService = require('../service/label'); 2 | const control = require('../lib/controller'); 3 | 4 | module.exports = { 5 | create: async (req, res) => { 6 | const { status, result } = await control( 7 | labelService.create, 8 | req.body, 9 | 201 10 | ); 11 | 12 | return res.status(status).json(result); 13 | }, 14 | read: async (req, res) => { 15 | const { status, result } = await control(labelService.read); 16 | 17 | return res.status(status).json(result); 18 | }, 19 | update: async (req, res) => { 20 | const { status, result } = await control(labelService.update, { 21 | ...req.body, 22 | ...req.params, 23 | }); 24 | 25 | return res.status(status).json(result); 26 | }, 27 | remove: async (req, res) => { 28 | const { status, result } = await control(labelService.remove, req.params); 29 | 30 | return res.status(status).json(result); 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /backend/src/controller/milestone.js: -------------------------------------------------------------------------------- 1 | const milestoneService = require('../service/milestone'); 2 | const control = require('../lib/controller'); 3 | 4 | module.exports = { 5 | create: async (req, res) => { 6 | const { status, result } = await control( 7 | milestoneService.create, 8 | req.body, 9 | 201 10 | ); 11 | 12 | return res.status(status).json(result); 13 | }, 14 | 15 | read: async (req, res) => { 16 | const { status, result } = await control(milestoneService.read); 17 | 18 | return res.status(status).json(result); 19 | }, 20 | 21 | readById: async (req, res) => { 22 | const { status, result } = await control( 23 | milestoneService.readById, 24 | req.params 25 | ); 26 | 27 | return res.status(status).json(result); 28 | }, 29 | 30 | update: async (req, res) => { 31 | const { status, result } = await control(milestoneService.update, { 32 | ...req.body, 33 | ...req.params, 34 | }); 35 | 36 | return res.status(status).json(result); 37 | }, 38 | 39 | remove: async (req, res) => { 40 | const { status, result } = await control( 41 | milestoneService.remove, 42 | req.params 43 | ); 44 | 45 | return res.status(status).json(result); 46 | }, 47 | 48 | updateState: async (req, res) => { 49 | const { status, result } = await control(milestoneService.updateState, { 50 | ...req.body, 51 | ...req.params, 52 | }); 53 | 54 | return res.status(status).json(result); 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /backend/src/controller/user.js: -------------------------------------------------------------------------------- 1 | const userService = require('../service/user'); 2 | const control = require('../lib/controller'); 3 | 4 | module.exports = { 5 | gitHubLogin: async (req, res) => { 6 | const { status, result } = await control(userService.gitHubLogin, req.data); 7 | 8 | return res.status(status).json(result); 9 | }, 10 | iOSAppleLogin: async (req, res) => { 11 | const { status, result } = await control( 12 | userService.iOSAppleLogin, 13 | req.body 14 | ); 15 | 16 | return res.status(status).json(result); 17 | }, 18 | 19 | getUser: (req, res) => { 20 | const { name, image } = req.user.dataValues; 21 | res.status(200).json({ name, image }); 22 | }, 23 | 24 | getUsers: async (req, res) => { 25 | const { status, result } = await control(userService.getUsers); 26 | 27 | return res.status(status).json(result); 28 | }, 29 | 30 | iOSGitHubLogin: async (req, res) => { 31 | const { status, result } = await control( 32 | userService.iOSGithubLogin, 33 | req.body 34 | ); 35 | 36 | return res.status(status).json(result); 37 | }, 38 | 39 | logout: (req, res) => { 40 | try { 41 | userService.logout(req.user); 42 | 43 | return res.status(200).json({ response: true }); 44 | } catch (error) { 45 | return res.status(500).json({ error }); 46 | } 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /backend/src/lib/controller.js: -------------------------------------------------------------------------------- 1 | module.exports = async (service, param, status = 200) => { 2 | try { 3 | const result = await service(param); 4 | 5 | if (!result.error) { 6 | return { status, result }; 7 | } 8 | return { status: 400, result: result.error }; 9 | } catch (error) { 10 | return { status: 500, result: error }; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /backend/src/lib/store.js: -------------------------------------------------------------------------------- 1 | const userList = {}; 2 | 3 | module.exports = { 4 | enrollList: (userId) => { 5 | userList[userId] = true; 6 | }, 7 | dodgeList: (userId) => { 8 | userList[userId] = false; 9 | }, 10 | checkList: (userId) => { 11 | if (userList[userId]) { 12 | return false; 13 | } 14 | return true; 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /backend/src/lib/utils/jwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const { JWT_SECRET } = process.env; 4 | module.exports = { 5 | createJWT: (id) => jwt.sign({ id }, JWT_SECRET), 6 | verifyJWT: (token) => { 7 | try { 8 | const id = jwt.verify(token, JWT_SECRET); 9 | return id; 10 | } catch (error) { 11 | return false; 12 | } 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /backend/src/lib/utils/multer.js: -------------------------------------------------------------------------------- 1 | const multer = require('multer'); 2 | 3 | const storage = multer.diskStorage({ 4 | destination: (req, file, cb) => { 5 | cb(null, 'uploads/'); 6 | }, 7 | filename: (req, file, cb) => { 8 | cb(null, new Date().getTime() + file.originalname); 9 | }, 10 | }); 11 | const uploads = multer({ storage }).single('img'); 12 | 13 | module.exports = { uploads }; 14 | -------------------------------------------------------------------------------- /backend/src/model/__test__/comment.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const SequelizeMock = require('sequelize-mock'); 3 | const Comment = require('../comment'); 4 | 5 | const sequelize = new SequelizeMock(); 6 | const DataTypes = sequelize.Sequelize; 7 | const model = Comment(sequelize, DataTypes); 8 | const schema = model._defaults; 9 | 10 | describe('Comment 모델 테스트', () => { 11 | it('모델명이 알맞은가', () => { 12 | expect(model.name).toBe('Comment'); 13 | }); 14 | 15 | it('모델의 Schema가 알맞는가', () => { 16 | expect(schema).toEqual({ 17 | id: { 18 | type: DataTypes.INTEGER, 19 | autoIncrement: true, 20 | primaryKey: true, 21 | }, 22 | content: { 23 | type: DataTypes.TEXT, 24 | allowNull: false, 25 | }, 26 | timestamp: { 27 | type: DataTypes.DATE, 28 | defaultValue: DataTypes.NOW, 29 | }, 30 | }); 31 | }); 32 | 33 | it('associate관계가 있는가', () => { 34 | expect(typeof model.associate).toBe('function'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /backend/src/model/__test__/issue.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const SequelizeMock = require('sequelize-mock'); 3 | const Issue = require('../issue'); 4 | 5 | const sequelize = new SequelizeMock(); 6 | const DataTypes = sequelize.Sequelize; 7 | const model = Issue(sequelize, DataTypes); 8 | const schema = model._defaults; 9 | 10 | describe('Issue모델 테스트', () => { 11 | it('모델명이 알맞은가', () => { 12 | expect(model.name).toBe('Issue'); 13 | }); 14 | 15 | it('모델의 Schema가 알맞는가', () => { 16 | expect(schema).toEqual({ 17 | id: { 18 | type: DataTypes.INTEGER, 19 | autoIncrement: true, 20 | primaryKey: true, 21 | }, 22 | title: { 23 | type: DataTypes.STRING(255), 24 | allowNull: false, 25 | }, 26 | is_opened: { 27 | type: DataTypes.BOOLEAN, 28 | defaultValue: true, 29 | }, 30 | timestamp: { 31 | type: DataTypes.DATE, 32 | defaultValue: DataTypes.NOW, 33 | }, 34 | }); 35 | }); 36 | 37 | it('associate관계가 있는가', () => { 38 | expect(typeof model.associate).toBe('function'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /backend/src/model/__test__/label.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const SequelizeMock = require('sequelize-mock'); 3 | const Label = require('../label'); 4 | 5 | const sequelize = new SequelizeMock(); 6 | const DataTypes = sequelize.Sequelize; 7 | const model = Label(sequelize, DataTypes); 8 | const schema = model._defaults; 9 | 10 | describe('Label모델 테스트', () => { 11 | it('모델명이 알맞은가', () => { 12 | expect(model.name).toBe('Label'); 13 | }); 14 | 15 | it('모델의 Schema가 알맞는가', () => { 16 | expect(schema).toEqual({ 17 | id: { 18 | type: DataTypes.INTEGER, 19 | autoIncrement: true, 20 | primaryKey: true, 21 | }, 22 | color: { 23 | type: DataTypes.STRING(7), 24 | allowNull: false, 25 | }, 26 | title: { 27 | type: DataTypes.STRING(255), 28 | allowNull: false, 29 | }, 30 | content: { 31 | type: DataTypes.TEXT, 32 | }, 33 | }); 34 | }); 35 | 36 | it('associate관계가 있는가', () => { 37 | expect(typeof model.associate).toBe('function'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /backend/src/model/__test__/milestone.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const SequelizeMock = require('sequelize-mock'); 3 | const Milestone = require('../milestone'); 4 | 5 | const sequelize = new SequelizeMock(); 6 | const DataTypes = sequelize.Sequelize; 7 | const model = Milestone(sequelize, DataTypes); 8 | const schema = model._defaults; 9 | 10 | describe('Milestone모델 테스트', () => { 11 | it('모델명이 알맞은가', () => { 12 | expect(model.name).toBe('Milestone'); 13 | }); 14 | 15 | it('모델의 Schema가 알맞는가', () => { 16 | expect(schema).toEqual({ 17 | id: { 18 | type: DataTypes.INTEGER, 19 | autoIncrement: true, 20 | primaryKey: true, 21 | }, 22 | title: { 23 | type: DataTypes.STRING(255), 24 | allowNull: false, 25 | }, 26 | content: { 27 | type: DataTypes.TEXT, 28 | }, 29 | deadline: { 30 | type: DataTypes.DATEONLY, 31 | }, 32 | is_opened: { 33 | type: DataTypes.BOOLEAN, 34 | defaultValue: true, 35 | }, 36 | }); 37 | }); 38 | 39 | it('associate관계가 있는가', () => { 40 | expect(typeof model.associate).toBe('function'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /backend/src/model/__test__/user.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const SequelizeMock = require('sequelize-mock'); 3 | const User = require('../user'); 4 | 5 | const sequelize = new SequelizeMock(); 6 | const DataTypes = sequelize.Sequelize; 7 | const model = User(sequelize, DataTypes); 8 | const schema = model._defaults; 9 | 10 | describe('User모델 테스트', () => { 11 | it('모델명이 알맞은가', () => { 12 | expect(model.name).toBe('User'); 13 | }); 14 | 15 | it('모델의 Schema가 알맞는가', () => { 16 | expect(schema).toEqual({ 17 | id: { 18 | type: DataTypes.INTEGER, 19 | autoIncrement: true, 20 | primaryKey: true, 21 | }, 22 | user_code: { 23 | type: DataTypes.STRING(255), 24 | allowNull: false, 25 | }, 26 | name: { 27 | type: DataTypes.STRING(50), 28 | allowNull: false, 29 | }, 30 | image: { 31 | type: DataTypes.STRING(255), 32 | defaultValue: '', 33 | }, 34 | }); 35 | }); 36 | 37 | it('associate관계가 있는가', () => { 38 | expect(typeof model.associate).toBe('function'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /backend/src/model/comment.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const Comment = sequelize.define('Comment', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true, 7 | }, 8 | content: { 9 | type: DataTypes.TEXT, 10 | allowNull: false, 11 | }, 12 | timestamp: { 13 | type: DataTypes.DATE, 14 | defaultValue: DataTypes.NOW, 15 | }, 16 | }); 17 | Comment.associate = (db) => { 18 | db.Comment.belongsTo(db.User, { 19 | foreignKey: 'user_id', 20 | }); 21 | db.Comment.belongsTo(db.Issue, { 22 | foreignKey: 'issue_id', 23 | }); 24 | }; 25 | return Comment; 26 | }; 27 | -------------------------------------------------------------------------------- /backend/src/model/index.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const Sequelize = require('sequelize'); 3 | 4 | // Config 5 | const { dbConfig } = require('../config'); 6 | 7 | const sequelize = new Sequelize(dbConfig); 8 | const User = require('./user')(sequelize, Sequelize); 9 | const Issue = require('./issue')(sequelize, Sequelize); 10 | const Comment = require('./comment')(sequelize, Sequelize); 11 | const Label = require('./label')(sequelize, Sequelize); 12 | const Milestone = require('./milestone')(sequelize, Sequelize); 13 | 14 | const db = { 15 | User, 16 | Issue, 17 | Comment, 18 | Label, 19 | Milestone, 20 | }; 21 | 22 | Object.keys(db).forEach((modelName) => { 23 | if (db[modelName].associate) { 24 | db[modelName].associate(db); 25 | } 26 | }); 27 | 28 | db.sequelize = sequelize; 29 | db.Sequelize = Sequelize; 30 | 31 | module.exports = db; 32 | -------------------------------------------------------------------------------- /backend/src/model/issue.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const Issue = sequelize.define('Issue', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true, 7 | }, 8 | title: { 9 | type: DataTypes.STRING(255), 10 | allowNull: false, 11 | }, 12 | is_opened: { 13 | type: DataTypes.BOOLEAN, 14 | defaultValue: true, 15 | }, 16 | timestamp: { 17 | type: DataTypes.DATE, 18 | defaultValue: DataTypes.NOW, 19 | }, 20 | }); 21 | Issue.associate = (db) => { 22 | db.Issue.belongsTo(db.User, { 23 | foreignKey: 'user_id', 24 | }); 25 | db.Issue.belongsTo(db.Milestone, { 26 | foreignKey: 'milestone_id', 27 | allowNull: true, 28 | }); 29 | db.Issue.hasMany(db.Comment, { 30 | foreignKey: 'issue_id', 31 | }); 32 | db.Issue.belongsToMany(db.User, { 33 | through: 'Assignee_Issue', 34 | as: 'Assignees', 35 | }); 36 | db.Issue.belongsToMany(db.Label, { 37 | through: 'Issue_Label', 38 | foreignKey: 'issue_id', 39 | }); 40 | }; 41 | return Issue; 42 | }; 43 | -------------------------------------------------------------------------------- /backend/src/model/label.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const Label = sequelize.define('Label', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true, 7 | }, 8 | color: { 9 | type: DataTypes.STRING(7), 10 | allowNull: false, 11 | }, 12 | title: { 13 | type: DataTypes.STRING(255), 14 | allowNull: false, 15 | }, 16 | content: { 17 | type: DataTypes.TEXT, 18 | }, 19 | }); 20 | Label.associate = (db) => { 21 | db.Label.belongsToMany(db.Issue, { 22 | through: 'Issue_Label', 23 | foreignKey: 'label_id', 24 | }); 25 | }; 26 | return Label; 27 | }; 28 | -------------------------------------------------------------------------------- /backend/src/model/milestone.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const Milestone = sequelize.define('Milestone', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true, 7 | }, 8 | title: { 9 | type: DataTypes.STRING(255), 10 | allowNull: false, 11 | }, 12 | content: { 13 | type: DataTypes.TEXT, 14 | }, 15 | deadline: { 16 | type: DataTypes.DATEONLY, 17 | }, 18 | is_opened: { 19 | type: DataTypes.BOOLEAN, 20 | defaultValue: true, 21 | }, 22 | }); 23 | Milestone.associate = (db) => { 24 | db.Milestone.hasMany(db.Issue, { 25 | foreignKey: 'milestone_id', 26 | allowNull: true, 27 | }); 28 | }; 29 | return Milestone; 30 | }; 31 | -------------------------------------------------------------------------------- /backend/src/model/user.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const User = sequelize.define('User', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | autoIncrement: true, 6 | primaryKey: true, 7 | }, 8 | user_code: { 9 | type: DataTypes.STRING(255), 10 | allowNull: false, 11 | }, 12 | name: { 13 | type: DataTypes.STRING(50), 14 | allowNull: false, 15 | }, 16 | image: { 17 | type: DataTypes.STRING(255), 18 | defaultValue: '', 19 | }, 20 | }); 21 | User.associate = (db) => { 22 | db.User.hasMany(db.Issue, { 23 | foreignKey: 'user_id', 24 | }); 25 | db.User.hasMany(db.Comment, { 26 | foreignKey: 'user_id', 27 | }); 28 | db.User.belongsToMany(db.Issue, { 29 | through: 'Assignee_Issue', 30 | as: 'Issue', 31 | }); 32 | }; 33 | return User; 34 | }; 35 | -------------------------------------------------------------------------------- /backend/src/passport/index.js: -------------------------------------------------------------------------------- 1 | const jwtStrategy = require('./jwt'); 2 | 3 | const initStrategy = () => { 4 | jwtStrategy(); 5 | }; 6 | 7 | module.exports = initStrategy; 8 | -------------------------------------------------------------------------------- /backend/src/passport/jwt.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); 3 | const User = require('../model').User; 4 | 5 | const options = { 6 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 7 | secretOrKey: process.env.JWT_SECRET, 8 | }; 9 | 10 | module.exports = () => { 11 | passport.use( 12 | new JwtStrategy(options, async function (jwt_payload, done) { 13 | try { 14 | const user = await User.findOne({ 15 | where: { id: jwt_payload.id }, 16 | }); 17 | 18 | if (user) { 19 | return done(null, user); 20 | } 21 | 22 | return done(null, false); 23 | } catch (error) { 24 | return done(error, false); 25 | } 26 | }) 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /backend/src/routes/comment.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | 4 | // Controller 5 | const comment = require('../controller/comment'); 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/', comment.create); 10 | 11 | router.get('/', comment.read); 12 | 13 | router.get('/:id', comment.readById); 14 | 15 | router.delete('/:id', comment.remove); 16 | 17 | router.put('/:id', comment.update); 18 | 19 | module.exports = router; 20 | -------------------------------------------------------------------------------- /backend/src/routes/image.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | const upload = require('../lib/utils/multer').uploads; 4 | 5 | // Controller 6 | const imageController = require('../controller/image'); 7 | 8 | const router = express.Router(); 9 | 10 | router.post('/', upload, imageController.upload); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /backend/src/routes/index.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | const jwtAuth = require('./middleware/jwt-auth'); 4 | 5 | // Router 6 | const userRouter = require('./user'); 7 | const commentRouter = require('./comment'); 8 | const imageRouter = require('./image'); 9 | const issueRouter = require('./issue'); 10 | const labelRouter = require('./label'); 11 | const milestoneRouter = require('./milestone'); 12 | 13 | const router = express.Router(); 14 | 15 | router.use('/user', userRouter); 16 | router.use(jwtAuth); 17 | router.use('/comment', commentRouter); 18 | router.use('/image', imageRouter); 19 | router.use('/issue', issueRouter); 20 | router.use('/label', labelRouter); 21 | router.use('/milestone', milestoneRouter); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /backend/src/routes/issue.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | 4 | // Controller 5 | const issueController = require('../controller/issue'); 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/', issueController.read); 10 | 11 | router.get('/:id', issueController.readById); 12 | 13 | router.post('/', issueController.create); 14 | 15 | router.put('/title/:id', issueController.updateTitle); 16 | 17 | router.put('/milestone/:id', issueController.updateMilestone); 18 | 19 | router.put('/state', issueController.updateState); 20 | 21 | router.put('/assignee/:id', issueController.updateAssignee); 22 | 23 | router.put('/assignees/:id', issueController.updateAssignees); 24 | 25 | router.put('/label/:id', issueController.updateLabel); 26 | 27 | router.put('/labels/:id', issueController.updateLabels); 28 | 29 | router.delete('/:id', issueController.remove); 30 | 31 | module.exports = router; 32 | -------------------------------------------------------------------------------- /backend/src/routes/label.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | 4 | // controller 5 | const labelController = require('../controller/label'); 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/', labelController.read); 10 | 11 | router.post('/', labelController.create); 12 | 13 | router.put('/:id', labelController.update); 14 | 15 | router.delete('/:id', labelController.remove); 16 | 17 | module.exports = router; 18 | -------------------------------------------------------------------------------- /backend/src/routes/middleware/git-auth.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | module.exports = async (req, res, next) => { 4 | try { 5 | const { code } = req.body; 6 | 7 | const response = await axios.post( 8 | 'https://github.com/login/oauth/access_token', 9 | { 10 | code, 11 | client_id: process.env.GITHUB_ID, // 내 APP의 정보 12 | client_secret: process.env.GITHUB_SECRET, // 내 APP의 정보 13 | }, 14 | { 15 | headers: { 16 | accept: 'application/json', 17 | }, 18 | } 19 | ); 20 | 21 | const token = response.data.access_token; 22 | 23 | const { 24 | data: { login, id, avatar_url }, 25 | } = await axios.get('https://api.github.com/user', { 26 | headers: { 27 | Authorization: `token ${token}`, 28 | }, 29 | }); 30 | 31 | req.data = { name: login, id, image: avatar_url }; 32 | } catch (error) { 33 | return res.status(500).json({ error: '로그인 실패' }); 34 | } 35 | next(); 36 | }; 37 | -------------------------------------------------------------------------------- /backend/src/routes/middleware/jwt-auth.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const passport = require('passport'); 3 | const { checkList } = require('../../lib/store'); 4 | 5 | module.exports = (req, res, next) => { 6 | passport.authenticate('jwt', { session: false }, (error, user) => { 7 | if (!user || checkList(user.id)) { 8 | return res.status(401).json('로그인 해주시기 바랍니다'); 9 | } 10 | if (error) { 11 | return res.status(401).json(error); 12 | } 13 | 14 | // 권한 등 체크 가능 15 | req.user = user; 16 | return next(); 17 | })(req, res, next); 18 | }; 19 | -------------------------------------------------------------------------------- /backend/src/routes/milestone.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | 4 | // Controller 5 | const milestoneController = require('../controller/milestone'); 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/', milestoneController.create); 10 | 11 | router.get('/', milestoneController.read); 12 | 13 | router.get('/:id', milestoneController.readById); 14 | 15 | router.put('/:id', milestoneController.update); 16 | 17 | router.put('/state/:id', milestoneController.updateState); 18 | 19 | router.delete('/:id', milestoneController.remove); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /backend/src/routes/user.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const express = require('express'); 3 | const githubAuth = require('./middleware/git-auth'); 4 | const jwtAuth = require('./middleware/jwt-auth'); 5 | 6 | // Controller 7 | const userController = require('../controller/user'); 8 | 9 | const router = express.Router(); 10 | 11 | router.get('/', jwtAuth, userController.getUser); 12 | 13 | router.get('/users', jwtAuth, userController.getUsers); 14 | 15 | router.post('/github', githubAuth, userController.gitHubLogin); 16 | 17 | router.post('/apple', userController.iOSAppleLogin); 18 | 19 | router.post('/github/ios', userController.iOSGitHubLogin); 20 | 21 | router.post('/logout', jwtAuth, userController.logout); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /backend/src/service/__test__/image.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const imageService = require('../image'); 3 | 4 | describe('read label service 테스트', () => { 5 | it('함수인가', () => { 6 | expect(typeof imageService.upload).toBe('function'); 7 | }); 8 | it('리턴 값이 object인가', () => { 9 | const result = imageService.upload({ filename: 'hello.jpg' }); 10 | expect(typeof result).toBe('object'); 11 | }); 12 | it('잘못된 정보는 에러를 반환하는가1', () => { 13 | const result = imageService.upload({ filename: 'hello' }); 14 | expect(result).toEqual({ error: '파일이 잘못되었거나 없습니다' }); 15 | }); 16 | it('잘못된 정보는 에러를 반환하는가2', () => { 17 | const result = imageService.upload(); 18 | expect(result).toEqual({ error: '파일이 잘못되었거나 없습니다' }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /backend/src/service/comment.js: -------------------------------------------------------------------------------- 1 | const Model = require('../model'); 2 | 3 | module.exports = { 4 | create: async ({ content, userId, issueId }) => { 5 | if (!content || !userId || !issueId) { 6 | return { error: '정보가 부족합니다' }; 7 | } 8 | 9 | const comment = await Model.Comment.create({ 10 | content: content, 11 | user_id: userId, 12 | issue_id: issueId, 13 | }); 14 | 15 | return comment; 16 | }, 17 | 18 | read: async ({ id } = {}) => { 19 | if (!id) { 20 | return { error: '정보가 부족합니다' }; 21 | } 22 | 23 | const comments = await Model.Comment.findAll({ 24 | where: { issue_id: id }, 25 | }); 26 | 27 | return { comments }; 28 | }, 29 | 30 | readById: async ({ id }) => { 31 | if (!id) { 32 | return { error: '정보가 부족합니다.' }; 33 | } 34 | 35 | const comments = await Model.Comment.findAll({ 36 | include: [{ model: Model.User }], 37 | where: { issue_id: id }, 38 | }); 39 | 40 | return { comments }; 41 | }, 42 | 43 | remove: async ({ id }) => { 44 | if (!id) { 45 | return { error: '정보가 부족합니다' }; 46 | } 47 | 48 | const comment = await Model.Comment.destroy({ where: { id } }); 49 | 50 | if (comment) { 51 | return { response: true }; 52 | } 53 | return { error: '존재하지 않는 댓글입니다' }; 54 | }, 55 | 56 | update: async ({ id, content }) => { 57 | if (!id) { 58 | return { error: '정보가 부족합니다' }; 59 | } 60 | 61 | const [comment] = await Model.Comment.update( 62 | { content }, 63 | { where: { id } } 64 | ); 65 | 66 | if (comment) { 67 | return { response: true }; 68 | } 69 | return { error: '존재하지 않는 댓글입니다' }; 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /backend/src/service/image.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | upload: ({ filename } = {}) => { 3 | if (!filename || !filename.match(/\.(jpeg|jpg|png)$/gi)) { 4 | return { error: '파일이 잘못되었거나 없습니다' }; 5 | } 6 | return { imageURL: process.env.SERVER_URL + filename }; 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /backend/src/service/label.js: -------------------------------------------------------------------------------- 1 | const Label = require('../model').Label; 2 | 3 | module.exports = { 4 | create: async ({ color, title, content } = {}) => { 5 | if (!color || !title) { 6 | return { error: '정보가 부족합니다' }; 7 | } 8 | return await Label.create({ color, title, content }); 9 | }, 10 | read: async () => { 11 | return { labels: await Label.findAll() }; 12 | }, 13 | update: async ({ id, color, title, content } = {}) => { 14 | if (!id || !color || !title) { 15 | return { error: '정보가 부족합니다' }; 16 | } 17 | const [label] = await Label.update( 18 | { color, title, content }, 19 | { where: { id } } 20 | ); 21 | if (label) { 22 | return { response: true }; 23 | } 24 | return { error: '없는 id값 입니다' }; 25 | }, 26 | remove: async ({ id } = {}) => { 27 | if (!id) { 28 | return { error: '없는 id값 입니다' }; 29 | } 30 | const label = await Label.destroy({ where: { id } }); 31 | if (label) { 32 | return { response: true }; 33 | } 34 | return { error: '없는 id값 입니다' }; 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /backend/src/service/user.js: -------------------------------------------------------------------------------- 1 | const { createJWT } = require('../lib/utils/jwt'); 2 | const User = require('../model').User; 3 | const { enrollList, dodgeList } = require('../lib/store'); 4 | 5 | module.exports = { 6 | gitHubLogin: async (user) => { 7 | const { id, image, name } = user; 8 | 9 | const [newUser] = await User.findOrCreate({ 10 | where: { user_code: 'g' + id }, 11 | defaults: { 12 | user_code: 'g' + id, 13 | name, 14 | image, 15 | }, 16 | }); 17 | const jwtToken = createJWT(newUser.id); 18 | enrollList(newUser.id); 19 | 20 | return { token: jwtToken, image, name }; 21 | }, 22 | iOSAppleLogin: async (data) => { 23 | const { code, name } = data; 24 | if (!name || !code) { 25 | return { error: '정보가 부족합니다' }; 26 | } 27 | 28 | const [result] = await User.findOrCreate({ 29 | where: { user_code: 'a' + code }, 30 | defaults: { 31 | user_code: 'a' + code, 32 | name: name, 33 | }, 34 | }); 35 | const { id } = result.dataValues; 36 | const jwtToken = createJWT(id); 37 | 38 | enrollList(id); 39 | return { token: jwtToken }; 40 | }, 41 | 42 | getUsers: async () => { 43 | const users = await User.findAll({ attributes: ['id', 'name', 'image'] }); 44 | return { assignee: users }; 45 | }, 46 | 47 | iOSGithubLogin: async ({ code, name, image }) => { 48 | if (!name || !code) { 49 | return { error: '정보가 부족합니다' }; 50 | } 51 | const [user] = await User.findOrCreate({ 52 | where: { user_code: 'g' + code }, 53 | defaults: { 54 | user_code: 'g' + code, 55 | name, 56 | image, 57 | }, 58 | }); 59 | 60 | enrollList(user.id); 61 | return { token: createJWT(user.id) }; 62 | }, 63 | 64 | logout: (user) => { 65 | dodgeList(user.id); 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: ['airbnb', 'plugin:prettier/recommended'], 8 | parser: 'babel-eslint', 9 | rules: { 10 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 11 | 'react/jsx-one-expression-per-line': 'off', 12 | 'react/require-default-props': 'off', 13 | 'no-plusplus': 'off', 14 | 'react/jsx-filename-extension': 'off', 15 | 'react/no-array-index-key': 'off', 16 | 'no-nested-ternary': 'off', 17 | 'react/prop-types': 'off', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/react 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=react 3 | 4 | ### react ### 5 | .DS_* 6 | *.log 7 | logs 8 | **/*.backup.* 9 | **/*.back.* 10 | 11 | node_modules 12 | bower_components 13 | 14 | *.sublime* 15 | 16 | psd 17 | thumb 18 | sketch 19 | 20 | dist 21 | src/config 22 | 23 | # End of https://www.toptal.com/developers/gitignore/api/react -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "arrowParens": "always", 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | ## 조원 2 | 3 | 박은식 심재익 주재우 4 | 5 | ### Directory Structure 6 | 7 | ``` 8 | +-- src 9 | +-- components 10 | +-- pages 11 | +-- stores 12 | +-- style 13 | ``` 14 | 15 | ### 실행 방법 16 | 17 | ``` 18 | git clone - 19 | cd client 20 | npm install 21 | mkdir src/config 22 | vi src/config/index.js 23 | npm run dev 24 | ``` 25 | 26 | ### config 파일 27 | 28 | ``` 29 | export const CALLBACK_URL = '' 30 | export const SERVER_URL = ''; 31 | export const CLIENT_URL = ''; 32 | 33 | ``` 34 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-react', '@babel/preset-env'], 3 | plugins: [ 4 | [ 5 | '@babel/plugin-transform-runtime', 6 | { 7 | useESModules: false, 8 | }, 9 | ], 10 | ['@babel/plugin-proposal-optional-chaining'], 11 | ], 12 | sourceType: 'unambiguous', 13 | }; 14 | -------------------------------------------------------------------------------- /client/deploy.sh: -------------------------------------------------------------------------------- 1 | cd issuetracker/IssueTracker-09/client; 2 | git pull; 3 | npm install; 4 | npm run build; -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/src/setUpTest.js'], 3 | }; 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-09/2b122cf807cfd97748b9c722db89d41fdbcdd7ce/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | IssueTracker 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | 4 | import { 5 | Callback, 6 | Login, 7 | Issues, 8 | IssueNew, 9 | IssueDetail, 10 | MilestoneNew, 11 | MilestoneEdit, 12 | Milestones, 13 | Labels, 14 | } from './pages'; 15 | import Layout from './components/Layout'; 16 | import { Overlay } from './components/Overlay'; 17 | import PrivateRoute from './lib/PrivateRoute'; 18 | 19 | const App = () => { 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default App; 40 | -------------------------------------------------------------------------------- /client/src/apis/comment.js: -------------------------------------------------------------------------------- 1 | import request from '../lib/axios'; 2 | 3 | const getCommentAPI = async (id) => { 4 | try { 5 | const { 6 | data: { comments }, 7 | } = await request({ 8 | method: 'get', 9 | params: `/comment/${id}`, 10 | }); 11 | return comments; 12 | } catch (error) { 13 | return false; 14 | } 15 | }; 16 | 17 | const createCommentAPI = async (content, issueId) => { 18 | try { 19 | const { data } = await request({ 20 | method: 'POST', 21 | params: '/comment', 22 | data: { 23 | content, 24 | issueId, 25 | }, 26 | }); 27 | 28 | return data; 29 | } catch (error) { 30 | return false; 31 | } 32 | }; 33 | 34 | const updateCommentAPI = async (id, content) => { 35 | try { 36 | await request({ 37 | method: 'PUT', 38 | params: `/comment/${id}`, 39 | data: { 40 | content, 41 | }, 42 | }); 43 | 44 | return true; 45 | } catch (error) { 46 | return false; 47 | } 48 | }; 49 | 50 | // export default getIssueByIdAPI; 51 | export { getCommentAPI, updateCommentAPI, createCommentAPI }; 52 | -------------------------------------------------------------------------------- /client/src/apis/label.js: -------------------------------------------------------------------------------- 1 | import request from '../lib/axios'; 2 | 3 | export const createLabelAPI = async (data) => { 4 | try { 5 | await request({ method: 'post', params: `/label`, data }); 6 | return true; 7 | } catch (error) { 8 | return false; 9 | } 10 | }; 11 | 12 | export const getLabelsAPI = async () => { 13 | try { 14 | const { 15 | data: { labels }, 16 | } = await request({ method: 'get', params: '/label' }); 17 | return labels; 18 | } catch (error) { 19 | return false; 20 | } 21 | }; 22 | 23 | export const updateLabelAPI = async (id, data) => { 24 | try { 25 | await request({ method: 'put', params: `/label/${id}`, data }); 26 | return true; 27 | } catch (error) { 28 | return false; 29 | } 30 | }; 31 | 32 | export const deleteLabelAPI = async (id) => { 33 | try { 34 | await request({ method: 'delete', params: `/label/${id}` }); 35 | return true; 36 | } catch (error) { 37 | return false; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/apis/milestone.js: -------------------------------------------------------------------------------- 1 | import request from '../lib/axios'; 2 | 3 | export const getMilestonesAPI = async () => { 4 | try { 5 | const { 6 | data: { milestones }, 7 | } = await request({ method: 'get', params: '/milestone' }); 8 | return milestones; 9 | } catch (error) { 10 | return false; 11 | } 12 | }; 13 | 14 | export const getMilestonesByIdAPI = async (id) => { 15 | try { 16 | const { data } = await request({ 17 | method: 'get', 18 | params: `/milestone/${id}`, 19 | }); 20 | 21 | return data; 22 | } catch (error) { 23 | return false; 24 | } 25 | }; 26 | 27 | export const createMilestoneAPI = async ({ title, content, deadline }) => { 28 | try { 29 | await request({ 30 | method: 'post', 31 | params: '/milestone', 32 | data: { 33 | title, 34 | content, 35 | deadline, 36 | }, 37 | }); 38 | 39 | return true; 40 | } catch (error) { 41 | return false; 42 | } 43 | }; 44 | 45 | export const updateMilestoneAPI = async (id, title, deadline, content) => { 46 | try { 47 | await request({ 48 | method: 'put', 49 | params: `/milestone/${id}`, 50 | data: { 51 | title, 52 | deadline, 53 | content, 54 | }, 55 | }); 56 | 57 | return true; 58 | } catch (error) { 59 | return false; 60 | } 61 | }; 62 | 63 | export const updateMilestoneStateAPI = async (id, isOpened) => { 64 | try { 65 | await request({ 66 | method: 'put', 67 | params: `/milestone/state/${id}`, 68 | data: { 69 | isOpened, 70 | }, 71 | }); 72 | return true; 73 | } catch (error) { 74 | return false; 75 | } 76 | }; 77 | 78 | export const removeMilestoneAPI = async (id) => { 79 | try { 80 | await request({ 81 | method: 'delete', 82 | params: `/milestone/${id}`, 83 | }); 84 | return true; 85 | } catch (error) { 86 | return false; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /client/src/apis/user.js: -------------------------------------------------------------------------------- 1 | import request from '../lib/axios'; 2 | 3 | export const loginAPI = async (code) => { 4 | try { 5 | const { 6 | data: { token, image, name }, 7 | } = await request({ 8 | method: 'post', 9 | params: '/user/github', 10 | data: { code }, 11 | }); 12 | 13 | if (token) { 14 | localStorage.setItem('jwt_token', token); 15 | } 16 | return { image, name }; 17 | } catch (error) { 18 | return false; 19 | } 20 | }; 21 | 22 | export const getUserAPI = async () => { 23 | try { 24 | const { data } = await request({ method: 'get', params: '/user' }); 25 | return { image: data.image, name: data.name }; 26 | } catch (error) { 27 | return false; 28 | } 29 | }; 30 | export const getUsersAPI = async () => { 31 | try { 32 | const { 33 | data: { assignee }, 34 | } = await request({ method: 'get', params: '/user/users' }); 35 | return assignee; 36 | } catch (error) { 37 | return false; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/components/Dropdown/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useParams, useLocation } from 'react-router-dom'; 3 | import { Details, Div, Summary } from './styled'; 4 | import { overlayElement } from '../Overlay'; 5 | import SelectMenu from '../SelectMenu'; 6 | import DropdownItem from '../DropdownItem'; 7 | 8 | const Dropdown = ({ title, action, changeState = null, serverData = null }) => { 9 | const [state, setState] = useState(null); 10 | const param = useParams(); 11 | const location = useLocation(); 12 | 13 | const clickHandler = async () => { 14 | if (!state) { 15 | const result = await action(); 16 | setState(result); 17 | } 18 | }; 19 | 20 | return ( 21 |
22 | { 24 | overlayElement.current.hidden = false; 25 | }} 26 | > 27 | {title} 28 | 29 |
30 | {param.id || location.pathname === '/issues/new' ? ( 31 | 37 | ) : ( 38 | 39 | )} 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default Dropdown; 46 | -------------------------------------------------------------------------------- /client/src/components/Dropdown/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Div = styled.div` 4 | position: ${(props) => props.position}; 5 | display: flex; 6 | width: ${(props) => props.width}; 7 | margin: ${(props) => props.margin}; 8 | justify-content: ${(props) => props.align}; 9 | padding: ${(props) => props.padding}; 10 | border: ${(props) => props.border}; 11 | `; 12 | 13 | const Details = styled.details` 14 | padding: 10px; 15 | summary::-webkit-details-marker { 16 | display: none; 17 | } 18 | `; 19 | 20 | const Summary = styled.summary` 21 | outline: none; 22 | cursor: pointer; 23 | `; 24 | 25 | export { Div, Details, Summary }; 26 | -------------------------------------------------------------------------------- /client/src/components/DropdownItem/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Modal, 4 | Title, 5 | ListItem, 6 | DummyImage, 7 | Name, 8 | Image, 9 | Color, 10 | Input, 11 | } from './styled'; 12 | 13 | const DropdownItem = ({ title, data, changeState, serverData }) => { 14 | const clickHandler = (item) => { 15 | changeState(item); 16 | }; 17 | const [inputValue, setInputValue] = useState(''); 18 | 19 | const checkItem = (item) => { 20 | if ( 21 | (item.title && item.title.includes(inputValue)) || 22 | (item.name && item.name.includes(inputValue)) || 23 | inputValue === '' 24 | ) { 25 | return true; 26 | } 27 | return false; 28 | }; 29 | 30 | return ( 31 | 32 | {title} 33 | setInputValue(e.target.value)} 37 | /> 38 | {data?.map((item, index) => { 39 | if (!checkItem(item)) { 40 | return null; 41 | } 42 | return ( 43 | clickHandler(item)}> 44 | {item.image === '' ? ( 45 | <> 46 | 47 | {item.name} 48 | 49 | ) : item.image ? ( 50 | <> 51 | 52 | {item.name} 53 | 54 | ) : ( 55 | '' 56 | )} 57 | {item.color ? : ''} 58 | {item.title ? {item.title} : ''} 59 | {serverData.some((d) => d?.id === item.id) ? ( 60 |
v
61 | ) : ( 62 |
63 | )} 64 |
65 | ); 66 | })} 67 |
68 | ); 69 | }; 70 | 71 | export default DropdownItem; 72 | -------------------------------------------------------------------------------- /client/src/components/DropdownItem/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Modal = styled.div` 4 | background-color: white; 5 | position: absolute; 6 | width: 150px; 7 | height: 200px; 8 | padding: 10px; 9 | border: 1px solid black; 10 | border-radius: 5px; 11 | z-index: 100; 12 | overflow: auto; 13 | `; 14 | 15 | export const Title = styled.h2``; 16 | 17 | export const ListItem = styled.div` 18 | display: flex; 19 | margin: 3px 5px; 20 | padding: 3px 0; 21 | width: 100%; 22 | justify-content: space-between; 23 | align-items: center; 24 | border-bottom: 1px solid gray; 25 | cursor: pointer; 26 | `; 27 | 28 | export const DummyImage = styled.div` 29 | border-radius: 10px; 30 | display: block; 31 | background-color: gray; 32 | width: 20px; 33 | height: 20px; 34 | `; 35 | 36 | export const Color = styled.div` 37 | background-color: ${(props) => props.color}; 38 | width: 20px; 39 | height: 20px; 40 | border-radius: 10px; 41 | `; 42 | 43 | export const Name = styled.span` 44 | font-size: 13px; 45 | margin-left: 10px; 46 | `; 47 | export const Image = styled.div` 48 | background-image: url(${(props) => props.image}); 49 | background-size: cover; 50 | border-radius: 10px; 51 | display: block; 52 | width: 20px; 53 | height: 20px; 54 | `; 55 | 56 | export const Input = styled.input` 57 | margin: 0 auto; 58 | width: 90%; 59 | `; 60 | -------------------------------------------------------------------------------- /client/src/components/EditLabel/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const FlexDiv = styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | margin: 5px 0; 8 | `; 9 | 10 | export const LabelIcon = styled.div` 11 | padding: 7px 12px; 12 | border-radius: 15px; 13 | color: white; 14 | background-color: ${(props) => props.color}; 15 | `; 16 | export const Div = styled.div` 17 | padding: 3px; 18 | margin: 3px; 19 | width: 25%; 20 | `; 21 | 22 | export const Input = styled.input` 23 | margin: 5px 0; 24 | padding: 5px; 25 | width: 100%; 26 | border: 1px solid black; 27 | border-radius: 5px; 28 | `; 29 | 30 | export const ColorInput = styled.input` 31 | display: inline; 32 | margin: 5px; 33 | padding: 5px; 34 | width: 80%; 35 | border: 1px solid black; 36 | border-radius: 5px; 37 | `; 38 | 39 | export const Container = styled.div` 40 | border-bottom: 1px solid #dddddd; 41 | background-color: #eeeeee; 42 | padding: 10px; 43 | border-radius: 10px; 44 | `; 45 | 46 | export const Refresh = styled.div` 47 | background-color: ${(props) => props.color}; 48 | width: 30px; 49 | height: 30px; 50 | border-radius: 15px; 51 | `; 52 | 53 | export const Cancel = styled.button` 54 | padding: 5px 10px; 55 | margin: 3px; 56 | background-color: white; 57 | border: 1px solid black; 58 | border-radius: 5px; 59 | `; 60 | 61 | export const Submit = styled.button` 62 | padding: 5px 10px; 63 | margin: 3px; 64 | font-weight: bold; 65 | background-color: #2c974b; 66 | color: white; 67 | border: 1px solid #2c974b; 68 | border-radius: 5px; 69 | `; 70 | -------------------------------------------------------------------------------- /client/src/components/IssueItem/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/forbid-prop-types */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { faFlag } from '@fortawesome/free-solid-svg-icons'; 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 6 | 7 | import { 8 | Checkbox, 9 | Issue, 10 | Id, 11 | Item, 12 | Assignee, 13 | Assignees, 14 | Closed, 15 | Text, 16 | Title, 17 | Top, 18 | Label, 19 | Milestone, 20 | Bottom, 21 | } from './styled'; 22 | 23 | const Issues = ({ issue }) => { 24 | return ( 25 | 26 | 27 | 28 | 29 | ! 30 | {issue.title} 31 | {issue.Labels.map((label, index) => ( 32 | 35 | ))} 36 | 37 | 38 | #{issue.id} 39 | 40 | {issue.is_opened 41 | ? `opened yesterday by qkrdmstlr3` 42 | : `closed by qkrdmstlr3 yesterday`} 43 | 44 | 45 | {issue.Milestone.title} 46 | 47 | 48 | 49 | {issue.Assignees.map((assignee, index) => ( 50 | 51 | ))} 52 | 53 | 54 | ); 55 | }; 56 | 57 | Issues.propTypes = { 58 | issue: PropTypes.shape({ 59 | id: PropTypes.number, 60 | title: PropTypes.string, 61 | is_opened: PropTypes.bool, 62 | Assignees: PropTypes.array, 63 | Labels: PropTypes.array, 64 | Milestone: PropTypes.shape({ 65 | id: PropTypes.number, 66 | title: PropTypes.string, 67 | content: PropTypes.string, 68 | deadline: PropTypes.string, 69 | is_opened: PropTypes.bool, 70 | }), 71 | }), 72 | }; 73 | 74 | export default Issues; 75 | -------------------------------------------------------------------------------- /client/src/components/IssueItem/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Checkbox = styled.input` 4 | margin-top: 15px; 5 | `; 6 | 7 | export const Item = styled.li` 8 | display: flex; 9 | height: 60px; 10 | padding-left: 10px; 11 | border: 1px solid lightGray; 12 | cursor: pointer; 13 | `; 14 | 15 | export const Issue = styled.div` 16 | width: 80%; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | `; 21 | export const Assignees = styled.div` 22 | width: 20%; 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | `; 27 | 28 | export const Assignee = styled.div` 29 | width: 20px; 30 | height: 20px; 31 | border-radius: 10px; 32 | background-image: url('${(props) => props.src}'); 33 | background-size: cover; 34 | `; 35 | 36 | export const Closed = styled.div` 37 | width: 15px; 38 | height: 15px; 39 | margin: 0 10px; 40 | display: flex; 41 | justify-content: center; 42 | align-items: center; 43 | font-size: 13px; 44 | font-weight: bold; 45 | color: ${(props) => (!props.is_opened ? 'green' : 'red')}; 46 | border: 1.5px solid ${(props) => (!props.is_opened ? 'green' : 'red')}; 47 | border-radius: 8px; 48 | `; 49 | 50 | export const Top = styled.div` 51 | height: 20px; 52 | display: flex; 53 | align-items: center; 54 | `; 55 | 56 | export const Bottom = styled.div` 57 | height: 20px; 58 | padding-left: 35px; 59 | display: flex; 60 | align-items: center; 61 | font-size: 14px; 62 | color: gray; 63 | `; 64 | 65 | export const Title = styled.h2` 66 | font-weight: bold; 67 | `; 68 | 69 | export const Milestone = styled.span``; 70 | 71 | export const Id = styled.span``; 72 | 73 | export const Text = styled.span` 74 | margin: 0 5px; 75 | `; 76 | 77 | export const Label = styled.div` 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | padding: 3px 5px; 82 | margin: 0 10px; 83 | background-color: ${(props) => props.color}; 84 | color: white; 85 | font-weight: bold; 86 | border-radius: 10px; 87 | `; 88 | -------------------------------------------------------------------------------- /client/src/components/IssueList/dummy.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 5, 4 | title: '이슈 입력', 5 | is_opened: true, 6 | timestamp: '2020-11-03T12:20:25.000Z', 7 | Assignees: [ 8 | { 9 | id: 1, 10 | user_code: 'g46195613', 11 | name: 'joojaewoo', 12 | image: 'https://avatars2.githubusercontent.com/u/46195613?v=4', 13 | }, 14 | ], 15 | Milestone: { 16 | id: 1, 17 | title: 'title', 18 | content: '수정 테스트', 19 | deadline: '2020-11-05', 20 | is_opened: true, 21 | }, 22 | User: { 23 | id: 1, 24 | user_code: 'g46195613', 25 | name: 'joojaewoo', 26 | image: 'https://avatars2.githubusercontent.com/u/46195613?v=4', 27 | }, 28 | Labels: [ 29 | { 30 | id: 2, 31 | color: '#975917', 32 | title: 'test', 33 | content: '입력 테스트', 34 | }, 35 | ], 36 | }, 37 | { 38 | id: 5, 39 | title: '이슈 입력', 40 | is_opened: true, 41 | timestamp: '2020-11-03T12:20:25.000Z', 42 | Assignees: [ 43 | { 44 | id: 1, 45 | user_code: 'g46195613', 46 | name: 'joojaewoo', 47 | image: 'https://avatars2.githubusercontent.com/u/46195613?v=4', 48 | }, 49 | ], 50 | Milestone: { 51 | id: 1, 52 | title: 'title', 53 | content: '수정 테스트', 54 | deadline: '2020-11-05', 55 | is_opened: true, 56 | }, 57 | User: { 58 | id: 1, 59 | user_code: 'g46195613', 60 | name: 'joojaewoo', 61 | image: 'https://avatars2.githubusercontent.com/u/46195613?v=4', 62 | }, 63 | Labels: [ 64 | { 65 | id: 2, 66 | color: '#975917', 67 | title: 'test', 68 | content: '입력 테스트', 69 | }, 70 | ], 71 | }, 72 | ]; 73 | -------------------------------------------------------------------------------- /client/src/components/IssueList/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IssueItem from '../IssueItem'; 3 | import dummy from './dummy'; 4 | 5 | import { List } from './styled'; 6 | 7 | const IssueList = () => { 8 | return ( 9 | 10 | {dummy.map((issue, index) => ( 11 | 12 | ))} 13 | 14 | ); 15 | }; 16 | 17 | export default IssueList; 18 | -------------------------------------------------------------------------------- /client/src/components/IssueList/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const List = styled.ul` 5 | width: 60%; 6 | margin: 0 auto; 7 | `; 8 | -------------------------------------------------------------------------------- /client/src/components/LabelMileNavForm/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { faTag, faFlag } from '@fortawesome/free-solid-svg-icons'; 3 | import { Link, useLocation } from 'react-router-dom'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { Container, LinkButtons, LinkName, NewButton, NewBox } from './styled'; 6 | import EditLabel from '../EditLabel'; 7 | import { createLabelAPI } from '../../apis/label'; 8 | 9 | const LabelMileNavForm = ({ title, getLabels }) => { 10 | const [state, setState] = useState(false); 11 | const location = useLocation(); 12 | 13 | const handleClick = () => { 14 | setState(!state); 15 | }; 16 | 17 | const createHandler = async (data) => { 18 | const result = await createLabelAPI(data); 19 | if (result) { 20 | await getLabels(); 21 | handleClick(); 22 | } 23 | }; 24 | 25 | return ( 26 | <> 27 | 28 | 29 | 30 | 31 | 32 | Labels 33 | 34 | 35 | 36 | 37 | 38 | Milestones 39 | 40 | 41 | 42 | {title === 'Label' ? ( 43 | New {title} 44 | ) : ( 45 | 46 | New {title} 47 | 48 | )} 49 | 50 | {state ? ( 51 | 52 | 58 | 59 | ) : ( 60 | '' 61 | )} 62 | 63 | ); 64 | }; 65 | 66 | export default LabelMileNavForm; 67 | -------------------------------------------------------------------------------- /client/src/components/LabelMileNavForm/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | width: 60%; 5 | margin: 0 auto; 6 | margin-top: 50px; 7 | display: flex; 8 | justify-content: space-between; 9 | `; 10 | 11 | export const LinkButtons = styled.div` 12 | display: flex; 13 | `; 14 | 15 | export const LinkName = styled.div` 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | padding: 7px 15px; 20 | font-size: 15px; 21 | font-weight: bold; 22 | border: 1px solid ${(props) => (props.path ? '#6495ED' : 'lightgray')}; 23 | border-radius: 5px; 24 | cursor: pointer; 25 | background-color: ${(props) => (props.path ? '#6495ED' : 'white')}; 26 | color: ${(props) => (props.path ? 'white' : 'black')}; 27 | &:hover { 28 | background-color: lightgray; 29 | } 30 | `; 31 | 32 | export const NewButton = styled.button` 33 | background-color: white; 34 | border: none; 35 | padding: 10px 10px; 36 | color: white; 37 | background-color: ${(props) => props.theme.greenColor}; 38 | font-weight: bold; 39 | border-radius: 7px; 40 | cursor: pointer; 41 | `; 42 | 43 | export const NewBox = styled.div` 44 | width: 60%; 45 | margin: 0 auto; 46 | margin-top: 15px; 47 | border: 1px; 48 | display: ${(props) => props.display}; 49 | `; 50 | -------------------------------------------------------------------------------- /client/src/components/Layout/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { faBook } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { Header, Issue } from './styled'; 6 | import { CLIENT_URL } from '../../config'; 7 | 8 | import { UserContext } from '../../stores/userStore'; 9 | 10 | const Layout = () => { 11 | const { 12 | userState: { name }, 13 | } = useContext(UserContext); 14 | 15 | if (!name) { 16 | return null; 17 | } 18 | 19 | const clickHandler = () => { 20 | window.location.href = CLIENT_URL; 21 | }; 22 | 23 | return ( 24 |
25 | 26 | clickHandler(true)}>Issues 27 |
28 | ); 29 | }; 30 | 31 | export default Layout; 32 | -------------------------------------------------------------------------------- /client/src/components/Layout/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Header = styled.header` 4 | width: 100%; 5 | height: 80px; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | background-color: ${(props) => props.theme.blackColor}; 10 | `; 11 | 12 | export const Issue = styled.h1` 13 | margin: 0 20px; 14 | color: ${(props) => props.theme.whiteColor}; 15 | font-size: 30px; 16 | font-weight: bold; 17 | cursor: pointer; 18 | `; 19 | -------------------------------------------------------------------------------- /client/src/components/MilestoneBox/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div``; 4 | 5 | export const ContentsBox = styled.div` 6 | margin-bottom: 10px; 7 | `; 8 | 9 | export const Title = styled.div` 10 | font-size: 14px; 11 | line-height: 1.5; 12 | font-weight: 600; 13 | `; 14 | 15 | export const TitleInput = styled.input` 16 | padding: 5px 12px; 17 | width: 440px; 18 | max-width: 100%; 19 | margin-rigth: 5px; 20 | border-radius: 6px; 21 | border: 1px solid #e7e9ec; 22 | background-color: #fafbfc; 23 | line-height: 20px; 24 | placeholder: ${(props) => props.placeholder}; 25 | type: ${(props) => props.type}; 26 | `; 27 | 28 | export const DescriptionInput = styled.textarea` 29 | padding: 5px 12px; 30 | width: 60%; 31 | height: 200px; 32 | min-height: 200px; 33 | margin-rigth: 5px; 34 | border-radius: 6px; 35 | border: 1px solid #e7e9ec; 36 | background-color: #fafbfc; 37 | `; 38 | 39 | export const ButtonBox = styled.div` 40 | display: flex; 41 | justify-content: flex-end; 42 | `; 43 | 44 | export const Button = styled.button` 45 | background-color: white; 46 | border-color: #1b1f2326; 47 | margin-left: 3px; 48 | padding: 10px 10px; 49 | color: white; 50 | background-color: ${(props) => props.backgroundColor}; 51 | font-weight: bold; 52 | color: ${(props) => props.color}; 53 | border-radius: 7px; 54 | cursor: pointer; 55 | `; 56 | -------------------------------------------------------------------------------- /client/src/components/Overlay/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import React, { createRef } from 'react'; 3 | 4 | import Div from './styled'; 5 | 6 | const overlayElement = createRef(); 7 | 8 | const Overlay = () => { 9 | const clickHandler = (e) => { 10 | const details = Object.entries(document.getElementsByTagName('details')); 11 | details.forEach(([_, detail]) => { 12 | detail.toggleAttribute('open', false); 13 | }); 14 | e.target.hidden = true; 15 | }; 16 | return
clickHandler(e)} ref={overlayElement} />; 17 | }; 18 | 19 | export { Overlay, overlayElement }; 20 | -------------------------------------------------------------------------------- /client/src/components/Overlay/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Div = styled.div.attrs({ 4 | id: 'overlay', 5 | hidden: true, 6 | })` 7 | position: absolute; 8 | width: 100%; 9 | height: 100%; 10 | z-index: 1; 11 | `; 12 | 13 | export default Div; 14 | -------------------------------------------------------------------------------- /client/src/components/SelectMenu/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Modal = styled.div` 4 | position: relative; 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | left: 0; 9 | z-index: 2; 10 | display: flex; 11 | padding: 6px 3px; 12 | width: 150px; 13 | height: 200px; 14 | flex-direction: column; 15 | background: #ffffff; 16 | overflow: auto; 17 | border: 1px solid black; 18 | border-radius: 5px; 19 | `; 20 | 21 | const Div = styled.div` 22 | margin: 0 5px; 23 | font-size: 30px; 24 | font-weight: bold; 25 | text-align: center; 26 | `; 27 | 28 | const Title = styled.span` 29 | flex: 1; 30 | font-size: 20px; 31 | font-weight: 600; 32 | `; 33 | const CloseButton = styled.span``; 34 | 35 | const ListItem = styled.div` 36 | display: flex; 37 | margin: 3px 5px; 38 | padding: 3px 0; 39 | width: 100%; 40 | align-items: center; 41 | border-bottom: 1px solid gray; 42 | `; 43 | 44 | const Name = styled.span` 45 | font-size: 13px; 46 | margin-left: 10px; 47 | `; 48 | const Image = styled.div` 49 | background-image: url(${(props) => props.image}); 50 | background-size: cover; 51 | border-radius: 10px; 52 | display: block; 53 | width: 20px; 54 | height: 20px; 55 | `; 56 | 57 | const DummyImage = styled.div` 58 | border-radius: 10px; 59 | display: block; 60 | background-color: gray; 61 | width: 20px; 62 | height: 20px; 63 | `; 64 | 65 | const Color = styled.div` 66 | background-color: ${(props) => props.color}; 67 | width: 20px; 68 | height: 20px; 69 | border-radius: 10px; 70 | `; 71 | 72 | const Input = styled.input` 73 | margin: 0 auto; 74 | width: 90%; 75 | `; 76 | 77 | export { 78 | Div, 79 | Modal, 80 | Title, 81 | CloseButton, 82 | ListItem, 83 | Image, 84 | Name, 85 | DummyImage, 86 | Color, 87 | Input, 88 | }; 89 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/Assignee/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Item = styled.div` 4 | margin: 3px 5px; 5 | padding: 3px 0; 6 | width: 100%; 7 | display: flex; 8 | align-items: center; 9 | `; 10 | 11 | export const DummyImage = styled.div` 12 | border-radius: 10px; 13 | display: block; 14 | background-color: gray; 15 | width: 20px; 16 | height: 20px; 17 | `; 18 | 19 | export const Name = styled.span` 20 | font-size: 13px; 21 | margin-left: 10px; 22 | `; 23 | export const Image = styled.div` 24 | background-image: url(${(props) => props.image}); 25 | background-size: cover; 26 | border-radius: 10px; 27 | display: block; 28 | width: 20px; 29 | height: 20px; 30 | `; 31 | 32 | export const Span = styled.span` 33 | font-size: 14px; 34 | padding-left: 10px; 35 | color: gray; 36 | `; 37 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/Label/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import { getLabelsAPI } from '../../../apis/label'; 3 | import Dropdown from '../../Dropdown'; 4 | import { Label, Span } from './styled'; 5 | import { updateLabelAPI } from '../../../apis/issue'; 6 | import { CreateInfoContext } from '../../../stores/createInfoStore'; 7 | 8 | const LabelContainer = ({ labels, issueId, type }) => { 9 | const [state, setState] = useState(labels || []); 10 | const { changeInfo } = useContext(CreateInfoContext); 11 | 12 | const changeState = async (item) => { 13 | const index = state.findIndex((s) => s.id === item.id); 14 | if (index !== -1) { 15 | if (type === 'modify') { 16 | await updateLabelAPI(issueId, state[index].id, false); 17 | } 18 | changeInfo( 19 | 'label', 20 | state.filter((s, i) => i !== index) 21 | ); 22 | return setState(state.filter((s, i) => i !== index)); 23 | } 24 | if (type === 'modify') { 25 | await updateLabelAPI(issueId, item.id, true); 26 | } 27 | changeInfo('label', [...state, item]); 28 | return setState([...state, item]); 29 | }; 30 | 31 | return ( 32 | <> 33 | 39 | {state.length ? ( 40 | state.map((item, index) => ( 41 | 44 | )) 45 | ) : ( 46 | None yet 47 | )} 48 | 49 | ); 50 | }; 51 | 52 | export default LabelContainer; 53 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/Label/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Span = styled.span` 4 | font-size: 14px; 5 | padding-left: 10px; 6 | color: gray; 7 | `; 8 | 9 | export const Label = styled.div` 10 | display: inline-block; 11 | background-color: ${(props) => props.color}; 12 | color: white; 13 | border-radius: 10px; 14 | padding: 4px 8px; 15 | margin-left: 10px; 16 | font-size: 14px; 17 | font-weight: bold; 18 | `; 19 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/Milestone/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 2 | /* eslint-disable jsx-a11y/no-static-element-interactions */ 3 | import React, { useState, useContext } from 'react'; 4 | import Dropdown from '../../Dropdown'; 5 | import { getMilestonesAPI } from '../../../apis/milestone'; 6 | import { Bar, Status, MileName, Span, Delete } from './styled'; 7 | import { updateMilestoneAPI } from '../../../apis/issue'; 8 | import { CreateInfoContext } from '../../../stores/createInfoStore'; 9 | 10 | const MilestoneContainer = ({ milestone, issueId, type }) => { 11 | const [state, setState] = useState(milestone || {}); 12 | const { changeInfo } = useContext(CreateInfoContext); 13 | 14 | const changeState = async (item) => { 15 | if (state.id !== item.id) { 16 | if (type === 'modify') { 17 | await updateMilestoneAPI(issueId, item.id); 18 | } 19 | changeInfo('milestone', item); 20 | return setState(item); 21 | } 22 | changeInfo('milestone', null); 23 | return null; 24 | }; 25 | 26 | const deleteHandler = async () => { 27 | if (type === 'modify') { 28 | await updateMilestoneAPI(issueId, null); 29 | } 30 | changeInfo('milestone', null); 31 | return setState({}); 32 | }; 33 | 34 | return ( 35 | <> 36 | 42 | {state?.id ? ( 43 | <> 44 | X Clear this milestone 45 | 46 | 51 | 52 | {state.title} 53 | 54 | ) : ( 55 | No Milestone 56 | )} 57 | 58 | ); 59 | }; 60 | 61 | export default MilestoneContainer; 62 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/Milestone/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const MileName = styled.span` 4 | display: inline-block; 5 | font-size: 14px; 6 | font-weight: bold; 7 | padding: 5px 0 0 10px; 8 | `; 9 | 10 | export const Bar = styled.div` 11 | width: calc(100% - 20px); 12 | height: 10px; 13 | border-radius: 4px; 14 | background-color: lightgray; 15 | margin: 0 auto; 16 | `; 17 | 18 | export const Status = styled.div` 19 | width: ${(props) => props.percentage}%; 20 | height: 10px; 21 | border-radius: 4px; 22 | background-color: green; 23 | `; 24 | 25 | export const Span = styled.span` 26 | font-size: 14px; 27 | padding-left: 10px; 28 | color: gray; 29 | `; 30 | 31 | export const Delete = styled.span` 32 | font-size: 11px; 33 | padding-left: 10px; 34 | cursor: pointer; 35 | &:hover { 36 | color: gray; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container, DropdownContainer } from './styled'; 3 | import AssigneeContainer from './Assignee'; 4 | import LabelContainer from './Label'; 5 | import MilestoneContainer from './Milestone'; 6 | 7 | const IssueSidebar = ({ issue = { id: 1000 }, type }) => { 8 | if (!issue.id) { 9 | return null; 10 | } 11 | 12 | return ( 13 | 14 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default IssueSidebar; 36 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/styled.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import styled from 'styled-components'; 3 | 4 | export const Container = styled.div` 5 | width: 30%; 6 | height: 500px; 7 | `; 8 | 9 | export const DropdownContainer = styled.div` 10 | position: relative; 11 | margin-bottom: 15px; 12 | padding-bottom: 15px; 13 | border-bottom: 1px solid lightgray; 14 | `; 15 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDom from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | 5 | import { ThemeProvider } from 'styled-components'; 6 | import theme from './style/theme'; 7 | import GlobalStyle from './style/global-style'; 8 | import App from './App'; 9 | 10 | import { UserProvider } from './stores/userStore'; 11 | import { IssueProvider } from './stores/issueStore'; 12 | import { CreateInfoProvider } from './stores/createInfoStore'; 13 | 14 | ReactDom.render( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | , 27 | document.getElementById('root') 28 | ); 29 | -------------------------------------------------------------------------------- /client/src/lib/PrivateRoute.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-curly-newline */ 2 | /* eslint-disable react/jsx-props-no-spreading */ 3 | /* eslint-disable react/prop-types */ 4 | import React, { useContext, useEffect } from 'react'; 5 | import { Route, Redirect } from 'react-router-dom'; 6 | import { UserContext } from '../stores/userStore'; 7 | 8 | const PrivateRoute = ({ component: Component, ...rest }) => { 9 | const { 10 | userState: { name, update }, 11 | userAction: { getUser }, 12 | } = useContext(UserContext); 13 | useEffect(async () => { 14 | await getUser(); 15 | }, []); 16 | 17 | if (name) { 18 | return } />; 19 | } 20 | if (update && !name) { 21 | return ; 22 | } 23 | return
hello
; 24 | }; 25 | 26 | export default PrivateRoute; 27 | -------------------------------------------------------------------------------- /client/src/lib/axios.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { SERVER_URL } from '../config'; 3 | 4 | axios.defaults.baseURL = `${SERVER_URL}/api`; 5 | const request = async ({ method, params = '', data = '' }) => { 6 | const token = localStorage.getItem('jwt_token'); 7 | 8 | const config = { 9 | method, 10 | url: `${params}`, 11 | headers: { 12 | Authorization: `bearer ${token}`, 13 | }, 14 | data, 15 | }; 16 | 17 | const result = await axios(config); 18 | return result; 19 | }; 20 | 21 | export default request; 22 | -------------------------------------------------------------------------------- /client/src/lib/calculate-time.js: -------------------------------------------------------------------------------- 1 | const timePerSeconds = { 2 | month: 2592000, 3 | day: 86400, 4 | hour: 3600, 5 | minute: 60, 6 | }; 7 | 8 | function calculateTime(targetTime) { 9 | const seconds = Math.floor((new Date() - new Date(targetTime)) / 1000); 10 | 11 | let interval = seconds / timePerSeconds.month; 12 | 13 | if (interval > 1) { 14 | return `${Math.floor(interval)} months`; 15 | } 16 | 17 | interval = seconds / timePerSeconds.day; 18 | if (interval > 1) { 19 | return `${Math.floor(interval)} days`; 20 | } 21 | 22 | interval = seconds / timePerSeconds.hour; 23 | if (interval > 1) { 24 | return `${Math.floor(interval)} hours`; 25 | } 26 | 27 | interval = seconds / timePerSeconds.minute; 28 | if (interval > 1) { 29 | return `${Math.floor(interval)} minutes`; 30 | } 31 | 32 | return `${Math.floor(seconds)} seconds`; 33 | } 34 | 35 | export default calculateTime; 36 | -------------------------------------------------------------------------------- /client/src/lib/make-search.js: -------------------------------------------------------------------------------- 1 | const makeSearch = (str, search = '') => { 2 | const diff = str.split(':')[0]; 3 | const searchArr = search.split(' ').filter((item) => item !== ''); 4 | let flag = false; 5 | const newSearch = searchArr.map((item) => { 6 | if (item.includes(diff)) { 7 | flag = true; 8 | return str; 9 | } 10 | return item; 11 | }); 12 | 13 | if (!flag) { 14 | newSearch.push(str); 15 | } 16 | return `?q=${newSearch.join(' ')}`; 17 | }; 18 | 19 | export default makeSearch; 20 | -------------------------------------------------------------------------------- /client/src/pages/Callback/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { useEffect, useContext } from 'react'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import qs from 'qs'; 5 | import { UserContext } from '../../stores/userStore'; 6 | import Image from './styled'; 7 | 8 | const Callback = ({ history, location }) => { 9 | const { 10 | userAction: { loginUser }, 11 | } = useContext(UserContext); 12 | 13 | useEffect(async () => { 14 | const { code } = qs.parse(location.search, { 15 | ignoreQueryPrefix: true, 16 | }); 17 | await loginUser(code); 18 | return history.push('/'); 19 | }, []); 20 | 21 | return ; 22 | }; 23 | 24 | export default Callback; 25 | -------------------------------------------------------------------------------- /client/src/pages/Callback/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Image = styled.img.attrs({ 4 | src: 5 | 'https://raw.githubusercontent.com/qkrdmstlr3/svg-icon-animation/master/react-icon/react-icon.gif', 6 | alt: 'github-icon', 7 | })` 8 | border-radius: 10px; 9 | display: block; 10 | position: absolute; 11 | left: 50%; 12 | top: 50%; 13 | transform: translate(-50%, -50%); 14 | width: ${(props) => props.width}; 15 | height: ${(props) => props.height}; 16 | `; 17 | 18 | export default Image; 19 | -------------------------------------------------------------------------------- /client/src/pages/IssueDetail/Comment/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/button-has-type */ 2 | import React, { useState } from 'react'; 3 | import Markdown from 'markdown-to-jsx'; 4 | import EditComment from '../EditComment'; 5 | import { 6 | FlexDiv, 7 | CommentContainer, 8 | Header, 9 | Body, 10 | Image, 11 | Button, 12 | Date, 13 | Title, 14 | Square, 15 | } from './styled'; 16 | import { updateCommentAPI } from '../../../apis/comment'; 17 | import calculateTime from '../../../lib/calculate-time'; 18 | 19 | const Comment = ({ comment }) => { 20 | const [state, setState] = useState(false); 21 | const [commentInfo, setComment] = useState(comment); 22 | 23 | const updateComment = async (input) => { 24 | const result = await updateCommentAPI(commentInfo.id, input); 25 | if (result) { 26 | const newComment = commentInfo; 27 | newComment.content = input; 28 | setComment(newComment); 29 | } 30 | setState(false); 31 | }; 32 | 33 | return ( 34 | 35 | 36 | {state ? ( 37 | 42 | ) : ( 43 | 44 |
45 |
46 | {commentInfo.User.name} 47 | commented {calculateTime(comment.timestamp)} ago 48 |
49 |
50 | 51 |
52 | 53 |
54 | 55 | {comment.content} 56 | 57 |
58 | )} 59 |
60 | ); 61 | }; 62 | 63 | export default Comment; 64 | -------------------------------------------------------------------------------- /client/src/pages/IssueDetail/Comment/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const FlexDiv = styled.div` 4 | display: flex; 5 | margin-bottom: 20px; 6 | `; 7 | 8 | export const Span = styled.span` 9 | width: 100%; 10 | font-size: 60px; 11 | font-weight: bolder; 12 | `; 13 | 14 | export const CommentContainer = styled.div` 15 | width: 100%; 16 | border: 1px solid #d2e1f7; 17 | border-radius: 5px; 18 | margin-left: 7px; 19 | `; 20 | export const Header = styled.div` 21 | position: relative; 22 | border-bottom: 1px solid #d2e1f7; 23 | padding: 5px; 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | background-color: #d2e1f7; 28 | `; 29 | export const Body = styled.div` 30 | padding: 10px; 31 | font-size: 14px; 32 | `; 33 | export const Image = styled.div` 34 | background-image: url(${(props) => props.image}); 35 | background-size: cover; 36 | border-radius: 10px; 37 | display: block; 38 | width: 30px; 39 | height: 30px; 40 | margin-right: 5px; 41 | `; 42 | 43 | export const Button = styled.button` 44 | background-color: white; 45 | border: 1px solid black; 46 | border-radius: 3px; 47 | `; 48 | 49 | export const Date = styled.span` 50 | padding-left: 5px; 51 | font-size: 14px; 52 | color: gray; 53 | `; 54 | 55 | export const Title = styled.span` 56 | padding: 5px; 57 | `; 58 | 59 | export const Square = styled.div` 60 | width: 10px; 61 | height: 10px; 62 | background-color: #d2e1f7; 63 | position: absolute; 64 | top: 50%; 65 | left: -5px; 66 | transform: translateY(-50%) rotate(45deg); 67 | `; 68 | -------------------------------------------------------------------------------- /client/src/pages/IssueDetail/EditComment/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/button-has-type */ 2 | import React, { useState } from 'react'; 3 | import { 4 | Textarea, 5 | WriteBox, 6 | Cancel, 7 | Submit, 8 | ButtonContainer, 9 | Header, 10 | Square, 11 | } from './styled'; 12 | 13 | const EditComment = ({ comment, action, update }) => { 14 | const [inputValue, setInputvalue] = useState(comment); 15 | 16 | return ( 17 | 18 |
19 | Write 20 | 21 |
22 |