├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── project-errors.yml │ ├── questions.yml │ └── typo-and-sentence-errors.yml ├── .gitignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── assets ├── graphql_book_signage.jpg └── graphql_book_signage_xl.jpg ├── bug-list.md ├── how-to-use.md ├── package.json ├── project ├── server │ ├── package.json │ ├── public │ │ ├── .gitkeep │ │ └── 1hwasurr-animoji.png │ ├── src │ │ ├── apollo │ │ │ ├── createApolloServer.ts │ │ │ ├── createSchema.ts │ │ │ └── createSubscriptionServer.ts │ │ ├── data │ │ │ └── ghibli.ts │ │ ├── dataloaders │ │ │ └── cutVoteLoader.ts │ │ ├── db │ │ │ └── db-client.ts │ │ ├── entities │ │ │ ├── Cut.ts │ │ │ ├── CutReview.ts │ │ │ ├── CutVote.ts │ │ │ ├── Director.ts │ │ │ ├── Film.ts │ │ │ ├── Notification.ts │ │ │ └── User.ts │ │ ├── index.ts │ │ ├── middlewares │ │ │ └── isAuthenticated.ts │ │ ├── redis │ │ │ └── redis-client.ts │ │ ├── resolvers │ │ │ ├── Cut.ts │ │ │ ├── CutReview.ts │ │ │ ├── Film.ts │ │ │ ├── Notification.ts │ │ │ └── User.ts │ │ └── utils │ │ │ └── jwt-auth.ts │ ├── tsconfig.json │ └── yarn.lock └── web │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── codegen.yml │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.tsx │ ├── apollo │ │ ├── auth.ts │ │ ├── createApolloCache.ts │ │ └── createApolloClient.ts │ ├── components │ │ ├── ColorModeSwitcher.tsx │ │ ├── CommonLayout.tsx │ │ ├── auth │ │ │ ├── LoginForm.tsx │ │ │ └── SignUpForm.tsx │ │ ├── film-cut │ │ │ ├── FilmCutDetail.tsx │ │ │ ├── FilmCutList.tsx │ │ │ ├── FilmCutModal.tsx │ │ │ ├── FilmCutReview.tsx │ │ │ ├── FilmCutReviewDelete.tsx │ │ │ └── FilmCutReviewRegiModal.tsx │ │ ├── film │ │ │ ├── FIlmDetail.tsx │ │ │ ├── FilmCard.tsx │ │ │ └── FilmList.tsx │ │ ├── nav │ │ │ └── Navbar.tsx │ │ └── notification │ │ │ ├── Notification.tsx │ │ │ └── NotificationItem.tsx │ ├── generated │ │ └── graphql.tsx │ ├── graphql │ │ ├── mutations │ │ │ ├── createOrUpdateCutReview.graphql │ │ │ ├── deleteCutReview.graphql │ │ │ ├── logIn.graphql │ │ │ ├── logout.graphql │ │ │ ├── refreshAccessToken.graphql │ │ │ ├── signUp.graphql │ │ │ ├── uploadProfileImage.graphql │ │ │ └── vote.graphql │ │ ├── queries │ │ │ ├── cut.graphql │ │ │ ├── cuts.graphql │ │ │ ├── film.graphql │ │ │ ├── films.graphql │ │ │ ├── me.graphql │ │ │ └── notifications.graphql │ │ └── subscriptions │ │ │ └── newNotification.graphql │ ├── index.tsx │ ├── pages │ │ ├── Film.tsx │ │ ├── Login.tsx │ │ ├── Main.tsx │ │ └── SignUp.tsx │ ├── react-app-env.d.ts │ └── reportWebVitals.ts │ ├── tsconfig.json │ └── yarn.lock ├── typo-list.md └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'airbnb', 6 | 'plugin:react/recommended', 7 | 'plugin:prettier/recommended', 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | plugins: ['react', 'react-hooks'], 12 | env: { 13 | browser: true, 14 | es6: true, 15 | node: true, 16 | }, 17 | rules: { 18 | 'max-len': [ 19 | 'error', 20 | { 21 | code: 80, 22 | tabWidth: 2, 23 | ignoreComments: true, 24 | ignoreTrailingComments: true, 25 | ignoreStrings: true, 26 | ignoreUrls: true, 27 | ignoreTemplateLiterals: true, 28 | ignorePattern: '^import\\s.+\\sfrom\\s.+;$', 29 | }, 30 | ], 31 | 'no-use-before-define': 'off', 32 | 'linebreak-style': 'off', 33 | camelcase: 'warn', 34 | 'max-classes-per-file': 'off', 35 | 'class-methods-use-this': 'off', 36 | 'import/prefer-default-export': 'off', 37 | 'import/extensions': [ 38 | 'error', 39 | 'ignorePackages', 40 | { 41 | js: 'never', 42 | ts: 'never', 43 | jsx: 'never', 44 | tsx: 'never', 45 | }, 46 | ], 47 | 'no-underscore-dangle': 0, 48 | // react 49 | 'react/jsx-filename-extension': [ 50 | 2, 51 | { extensions: ['.js', '.jsx', '.ts', '.tsx'] }, 52 | ], 53 | 'react/require-default-props': 0, 54 | 'react/jsx-props-no-spreading': 1, 55 | 'react/jsx-uses-react': 'off', 56 | 'react/react-in-jsx-scope': 'off', 57 | 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 58 | 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies 59 | // typescript 60 | '@typescript-eslint/no-use-before-define': 2, 61 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 62 | }, 63 | ignorePatterns: ['generated/**/*.tsx'], 64 | settings: { 65 | 'import/resolver': { 66 | node: { 67 | extensions: ['.js', '.ts', '.jsx', '.tsx'], 68 | }, 69 | }, 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: '📧 Email' 5 | url: 'mailto:iamsupermazinga@gmail.com' 6 | about: '저자에게 문의할 것이 있다면 메일을 보내주세요.' 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/project-errors.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 프로젝트 버그 2 | description: 프로젝트를 따라하던 도중 마주친 버그를 제보합니다. 3 | title: '[프로젝트버그] ' 4 | labels: ['bug'] 5 | assignees: 6 | - hwasurr 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: 먼저, 제보에 감사합니다!🥰 11 | 12 | - type: input 13 | validations: 14 | required: true 15 | attributes: 16 | label: '🐛 버그가 발견된 페이지' 17 | description: '버그가 발견된 내용이 포함된 쪽수를 알려주세요.' 18 | placeholder: 예) 97p ~ 102p 19 | 20 | - type: textarea 21 | validations: 22 | required: true 23 | attributes: 24 | label: '🐞 버그를 설명해주세요.' 25 | description: '발생한 버그 내용을 자세히 알려주세요.' 26 | placeholder: | 27 | 예) npm 설치 이후 npm 명령어가 올바르게 작동하지 않습니다. 28 | 29 | - type: textarea 30 | validations: 31 | required: true 32 | attributes: 33 | label: '🍉 버그를 재현할 방법을 알려주세요' 34 | description: '버그를 재현할 방법을 알려주세요. 가능하다면 순서를 알려주세요!' 35 | placeholder: | 36 | 예) 37 | 1. npm 설치 38 | 2. npm -v 시도했을 때 명령어를 찾을 수 없음. 39 | 40 | - type: checkboxes 41 | validations: 42 | required: true 43 | attributes: 44 | label: '🌏 오류가 발생한 환경의 OS는 무엇인가요?' 45 | options: 46 | - label: macOS 47 | - label: Windows 48 | - label: Linux 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/questions.yml: -------------------------------------------------------------------------------- 1 | name: 💬 질문 및 기타 2 | description: 질문사항이나 도움요청 등 기타 다른 문의사항 3 | title: '[질문/기타] ' 4 | labels: ['question'] 5 | assignees: 6 | - hwasurr 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: 먼저, 책을 읽어주셔서 감사합니다!🥰 11 | 12 | - type: textarea 13 | validations: 14 | required: true 15 | attributes: 16 | label: '🍥 질문사항 또는 도움요청 사항' 17 | description: '질문 또는 도움 요청 등 문의 사항을 입력해주세요.' 18 | placeholder: | 19 | 아무 내용이나 괜찮습니다. 저자의 개인정보같은 시덥잖은 내용도 좋습니다.^^ 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/typo-and-sentence-errors.yml: -------------------------------------------------------------------------------- 1 | name: 🍤 오타 및 문장오류 제보 2 | description: 도서에서 발견된 오타 및 문장오류를 제보합니다. 3 | title: '[오타/문장오류] ' 4 | labels: ['wontfix'] 5 | assignees: 6 | - hwasurr 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: 먼저, 제보에 감사합니다!🥰 11 | 12 | - type: input 13 | validations: 14 | required: true 15 | attributes: 16 | label: '🐛 오타 또는 문장 오류가 발견된 페이지' 17 | description: '오타 또는 문장 오류가 발견된 내용이 포함된 쪽수를 알려주세요.' 18 | placeholder: 예) 97p ~ 102p 19 | 20 | - type: textarea 21 | validations: 22 | required: true 23 | attributes: 24 | label: '🍔 오타 또는 문장 오류 내용' 25 | description: '발견한 오타나 어색한 문장을 자세히 알려주세요.' 26 | placeholder: | 27 | 예) 리액트 컴노펀트를 -> 리액트 컴포넌트를 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/react,node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=react,node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | # .env 79 | .env.test 80 | .env.production 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # Serverless directories 104 | .serverless/ 105 | 106 | # FuseBox cache 107 | .fusebox/ 108 | 109 | # DynamoDB Local files 110 | .dynamodb/ 111 | 112 | # TernJS port file 113 | .tern-port 114 | 115 | # Stores VSCode versions used for testing VSCode extensions 116 | .vscode-test 117 | 118 | # yarn v2 119 | .yarn/cache 120 | .yarn/unplugged 121 | .yarn/build-state.yml 122 | .yarn/install-state.gz 123 | .pnp.* 124 | 125 | ### react ### 126 | .DS_* 127 | **/*.backup.* 128 | **/*.back.* 129 | 130 | node_modules 131 | 132 | *.sublime* 133 | 134 | psd 135 | thumb 136 | sketch 137 | 138 | # End of https://www.toptal.com/developers/gitignore/api/react,node 139 | 140 | *.zip 141 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | singleQuote: true, 5 | trailingComma: "all", 6 | bracketSpacing: true, 7 | semi: true, 8 | useTabs: false, 9 | endOfLine: 'auto' 10 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "graphql.vscode-graphql", 4 | "formulahendry.auto-rename-tag", 5 | "oderwat.indent-rainbow", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // These are all my auto-save configs 3 | "editor.formatOnSave": true, 4 | // turn it off for JS and JSX, we will do this via eslint 5 | "[javascript]": { 6 | "editor.formatOnSave": false, 7 | "editor.tabSize": 2 8 | }, 9 | "[javascriptreact]": { 10 | "editor.formatOnSave": false, 11 | "editor.tabSize": 2 12 | }, 13 | "[typescript]": { 14 | "editor.autoClosingBrackets": "languageDefined", 15 | "editor.formatOnSave": false, 16 | "editor.tabSize": 2 17 | }, 18 | "[typescriptreact]": { 19 | "editor.autoClosingBrackets": "languageDefined", 20 | "editor.formatOnSave": false, 21 | "editor.tabSize": 2 22 | }, 23 | "eslint.alwaysShowStatus": true, 24 | "editor.codeActionsOnSave": { 25 | "source.fixAll.eslint": true 26 | } 27 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL과 타입스크립트로 개발하는 웹 서비스 2 | 3 | 본 저장소는 GraphQL과 타입스크립트로 개발하는 웹 서비스: 설계부터 개발·배포까지 따라 하며 완성하는 웹 풀스택 개발 도서의 예제 프로젝트 코드가 담겨있는 저장소입니다. 4 | 5 | [프로젝트 저장소 사용 방법](./how-to-use.md)에서 이 저장소를 어떻게 사용할 지에 대한 내용을 간략히 담았습니다. 질문 또는 오류 및 오타 제보등은 [이슈 게시판](https://github.com/hwasurr/graphql-book-fullstack-project/issues)을 활용해주세요. 6 | 7 | 독자분들의 소중한 제보를 통해 알려진 버그와 그 해결에 대한 내용은 [버그 목록 문서](bug-list.md)에서 확인할 수 있으며, 알려진 오타 목록은 [오타 목록 문서](typo-list.md)에서 확인할 수 있습니다. 8 | 9 | ## 책 소개 10 | 11 | GraphQL과 타입스크립트로 개발하는 웹 서비스 표지 12 | 13 | - 제목: GraphQL과 타입스크립트로 개발하는 웹 서비스 14 | - 부제: 설계부터 개발·배포까지 따라 하며 완성하는 웹 풀스택 개발 15 | - 출간일: 2022년 10월 14일 16 | - 페이지 수: 360쪽 17 | 18 | ### 간략 책 소개 19 | 20 | 지브리 영화 명장면에 ‘좋아요’와 ‘감상평’을 남기는 웹 서비스를 21 | GraphQL과 타입스크립트로 개발해 보자! 22 | 23 | GraphQL은 효과적인 API를 제공하기 위한 쿼리 언어이며 런타임 및 도구이다. 클라이언트/서버 구조의 중간자로서 양측에 공유되는 추상화된 데이터 명세 계층을 제시한다. 여러 글로벌 기업에서 GraphQL을 적극적으로 활용하고 있으며 국내 기업에서도 기존 또는 신규 프로젝트에 도입하는 사례가 많아지고 있다. 또한 GraphQL은 특정 언어나 플랫폼에 종속적이지 않아 지속해서 관련 프레임워크, 라이브러리, 서비스가 생산되고 있다. 24 | 25 | 이 책은 스튜디오 지브리 영화의 명장면 감상평 서비스를 함께 구현하면서 GraphQL을 자연스럽게 익힐 수 있도록 구성되어 있다. 백엔드 스키마 설계부터 프런트엔드 화면 구성과 서비스 배포에 이르기까지 순차적으로 따라 하며 하나의 웹 서비스를 개발해 볼 수 있다. 이 책에서 배운 내용을 바탕으로 본인이 원하는 주제의 웹 서비스를 개발해 보자. 26 | 27 | ## 온라인 서점 링크 28 | 29 | 1. [예스24](http://www.yes24.com/Product/Goods/114635182) 30 | 2. [교보문고](https://product.kyobobook.co.kr/detail/S000200019862) 31 | 3. [알라딘](https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=302978770&start=slayer) 32 | 4. [영풍문고](https://www.ypbooks.co.kr/book.yp?bookcd=101198108) 33 | 34 | ## 출판사 리뷰 35 | 36 | 웹 서비스를 직접 만들며 배우는 GraphQL 37 | 38 | 이 책은 단순한 이론 설명이 아닌 지브리 영화를 주제로 한 웹 서비스를 만들어 보면서 GraphQL에 대해 설명하여 재밌게 배울 수 있고 실무에도 유익하다. 기초 지식부터 단계별로 상세하게 설명하기 때문에 GraphQL을 처음 접하더라도 쉽게 이해하며 따라 해볼 수 있다. 백엔드부터 프런트엔드까지 직접 개발 및 배포한 경험을 바탕으로 여러분이 원하는 웹 서비스를 개발해 볼 수 있길 바란다. 39 | 40 | 이 책은 다음과 같이 구성되어 있다. 1장에서는 웹 개발의 역사부터 시작하여 GraphQL 탄생과 명세, REST와 GraphQL의 차이점, GraphQL이 해결할 수 있는 문제에 대해 알아본다. 2장에서는 Node.js + 타입스크립트 환경에서의 GraphQL 개발 생태계에 대해 알아본다. 3장부터는 함께 서비스를 구성하기 시작한다. Docker를 통해 Redis, MySQL을 구성하고 Node.js 개발 환경을 구성한다. 4장에서는 영화 목록에 대한 데이터베이스 모델과 GraphQL 스키마를 설계하고 쿼리를 통해 데이터를 불러오는 과정을 알아본다. 5장에서는 회원가입과 로그인, 감상평과 좋아요 기능을 구현하며 뮤테이션을 통해데이터를 조작하는 과정을 알아본다. 6장에서는 알림 기능을 구현하며 GraphQL을 통한 파일 업로드 방식에 대해 알아본다. 7장에서는 구현한 서비스를 AWS의 Beanstalk, S3를 통해 배포하는 과정을 진행한다. 41 | 42 | ## 저자 소개 43 | 44 | [강화수](https://github.com/hwasurr) 45 | 46 | 끝없는 성장을 목표하는 개발자다. 언제나 배움에 목말라하며 새로운 지식을 탐구하는 것과 성장하는 것에 즐거움을 느낀다. 작은 스타트업의 공동 창업자로 커리어를 시작했다. 그리고 개발팀의 리더로 설계, 프로젝트 관리, 백엔드/프런트엔드 개발, 인프라 구성 및 배포, 자동화에 이르기까지 서비스를 구성하기 위한 다양한 업무를 수행했다. 주로 Node.js, 타입스크립트, NestJS, React, GraphQL, Deno 등에 관심이 있다. 47 | 48 | - [블로그](https://hwasurr.io) 49 | - [이메일](mailto:iamsupermazinga@gmail.com) 50 | -------------------------------------------------------------------------------- /assets/graphql_book_signage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/assets/graphql_book_signage.jpg -------------------------------------------------------------------------------- /assets/graphql_book_signage_xl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/assets/graphql_book_signage_xl.jpg -------------------------------------------------------------------------------- /bug-list.md: -------------------------------------------------------------------------------- 1 | # 버그 목록 2 | 3 | 독자분들의 제보를 통해 알려진 버그의 목록과 그 해결방법에 대한 문서입니다. 4 | 제보해주신 분들께 깊은 감사의 말씀 드립니다. 5 | 6 | ## 바로가기 7 | 8 | 1. [.prettierrc endOfLine 옵션의 누락](#prettierrc-endofline-옵션의-누락) 9 | 2. [type-graphql의 graphql 16 버전 지원 관련 문제](#type-graphql의-graphql-16-버전-지원-관련-문제) 10 | 3. [(p. 112 두 번째 코드블럭) 여러 개의 Skeleton 컴포넌트를 렌더링 할 때, key가 동일한 값으로 설정되어 올바르지 않은 버그](#p-112-두-번째-코드블럭-여러-개의-skeleton-컴포넌트를-렌더링-할-때-동일한-값으로-key를-설정하는-문제) 11 | 12 | ### .prettierrc endOfLine 옵션의 누락 13 | 14 | 제보 이슈: [#2](https://github.com/hwasurr/graphql-book-fullstack-project/issues/2) 15 | 16 | p. 85 ~ p. 90 까지의 eslint, prettier 설정 과정 중, prettier 옵션의 누락이 있어 특정 개발 환경에서 개행문자로 인한 버그가 발생하였습니다. 17 | `endOfLine` 옵션의 값을 `auto` 로 지정하여 해결합니다. 18 | 19 | - 기존 20 | 21 | ```json 22 | { 23 | "printWidth": 120, 24 | "tabWidth": 2, 25 | "singleQuote": true, 26 | "trailingComma": "all", 27 | "bracketSpacing": true, 28 | "semi": true, 29 | "useTabs": false 30 | } 31 | ``` 32 | 33 | - 올바른 .prettierrc 또는 .prettierrc.js 34 | 35 | ```json 36 | { 37 | "printWidth": 120, 38 | "tabWidth": 2, 39 | "singleQuote": true, 40 | "trailingComma": "all", 41 | "bracketSpacing": true, 42 | "semi": true, 43 | "useTabs": false, 44 | "endOfLine": "auto" 45 | } 46 | ``` 47 | 48 | ### type-graphql의 graphql 16 버전 지원 관련 문제 49 | 50 | 제보 이슈: [#4](https://github.com/hwasurr/graphql-book-fullstack-project/issues/4), [#8](https://github.com/hwasurr/graphql-book-fullstack-project/issues/8) 51 | 52 | type-graphql의 현재(22. 12. 19.) 최신 배포 버전 1.1.1 에서는 graphql 16 버전을 지원하지 않는 것으로 보입니다. 53 | 54 | 책의 예제를 올바르게 실행하기 위해서는 현재 깃헙 리파지토리의 master 브랜치의 project/server와 project/web의 package.json 파일을 참고하여 버전을 그대로 따라가는 것이 안전하다고 말씀드립니다. 추가로, graphql 16 버전으로 작업하고 싶으시다면 다음 명령어를 통해 graphql 16버전과 호환되는 type-graphql의 베타버전을 설치하여 작업을 진행할 수 있습니다. 그러나 버전 업그레이드로 인해 생겨날 수 있는 새로운 다른 미지의 문제들은 따로 해결해 나가야할 것으로 보입니다. 버전을 변경한 이후에 발생하는 문제와 관련해서는 type-graphql 리파지토리의 이슈를 확인하거나 제안할 수 있습니다. 55 | 56 | ```bash 57 | # project/server 아래에서 58 | yarn add type-graphql@next 59 | ``` 60 | 61 | 또는 62 | 63 | ```bash 64 | # project/server 아래에서 65 | yarn add type-graphql@2.0.0-beta.1 66 | ``` 67 | 68 | ### (p. 112 두 번째 코드블럭) 여러 개의 Skeleton 컴포넌트를 렌더링 할 때, 동일한 값으로 key를 설정하는 문제 69 | 70 | 제보 이슈: [#10](https://github.com/hwasurr/graphql-book-fullstack-project/issues/10) 71 | 72 | - 기존 73 | 74 | ```typescript 75 | new Array(6).fill(0).map((x) => 76 | ``` 77 | 78 | - 올바른 코드블럭 79 | 80 | ```typescript 81 | // eslint-disable-next-line react/no-array-index-key 82 | new Array(6).fill(0).map((_, index) => 83 | ``` 84 | 85 | ### (p.293) 토큰 리프레시 링크 지정 관련 ApolloClient 전역변수 지정 누락 86 | 87 | 제보 이슈: [#5](https://github.com/hwasurr/graphql-book-fullstack-project/issues/5) 88 | 이슈 답변: https://github.com/hwasurr/graphql-book-fullstack-project/issues/5#issuecomment-1810211241 89 | 90 | - 기존 91 | 92 | ```typescript 93 | let apolloClient: ApolloClient; 94 | 95 | ... 링크들 96 | 97 | export const createApolloClient = (): ApolloClient => 98 | new ApolloClient({ 99 | cache: createApolloCache(), 100 | uri: `${process.env.REACT_APP_API_HOST}/graphql`, 101 | link: splitLink, 102 | }); 103 | ``` 104 | 105 | - 올바른 코드블럭 106 | 107 | ```ts 108 | let apolloClient: ApolloClient; 109 | 110 | ... 링크들 111 | 112 | export const createApolloClient = (): ApolloClient => { 113 | apolloClient = new ApolloClient({ 114 | cache: createApolloCache(), 115 | uri: `${process.env.REACT_APP_API_HOST}/graphql`, 116 | link: splitLink, 117 | }); 118 | return apolloClient; 119 | }; 120 | ``` 121 | -------------------------------------------------------------------------------- /how-to-use.md: -------------------------------------------------------------------------------- 1 | # 구성이 완료된 프로젝트 다운로드 설명 2 | 3 | 3장 4절의 "프로젝트 구성" 단계에서 문제가 생겨 더이상 프로젝트에 진행이 되지 않는 분들은 아래 설명을 따라 구성이 완료된 프로젝트에서 곧바로 시작할 수 있습니다. 4 | 5 | ## 준비사항 6 | 7 | 1. Git이 설치되어 있어야 합니다. 8 | 2. Github 아이디가 있고, 본인의 Github 아이디로 현재 로그인 되어 있어야 합니다. 9 | 10 | ## 주의 11 | 12 | Docker, MySQL, Redis, VSCode 와 같은 경우에는 책의 내용에 따라 설치를 진행하셔야 합니다. 13 | 14 | ## 설명 15 | 16 | 프로젝트 진행에 필요한 구성이 완료된 상태를 그대로 가져가 작업하시기 위한 작업에 대한 설명입니다. 17 | 18 | 1. 먼저 Github Fork를 진행합니다. 19 | 20 | 현재 페이지 우측 상단의 `Fork` 버튼을 눌러 자신의 Github 저장소로 현재 저장소를 복제합니다. 21 | 22 | 2. 생성된 저장소의 코드를 `clone` 하여 로컬 환경에 작업환경을 생성합니다. 23 | 24 | ```bash 25 | # 프로젝트를 진행할 디렉토리에서 26 | git clone https://github.com/<자신의Github아이디>/graphql-book-fullstack-project 27 | ``` 28 | 29 | 3. 원격에 존재하는 `chapter-3` 브랜치로 현재 브랜치를 변경합니다. `chapter-3` 브랜치는 3챕터까지의 내용만 반영되어 있어, 프로젝트가 구성된 상태까지만 가져와 작업을 시작할 수 있습니다. 30 | 31 | ```bash 32 | git checkout chapter-3 33 | ``` 34 | 35 | 미리 구성된 접근 가능한 브랜치는 다음과 같습니다. 36 | 37 | - 완성된 프로젝트: [master](https://github.com/hwasurr/graphql-book-fullstack-project/tree/master) 38 | - 챕터별 구성된 브랜치 39 | - [chapter-3](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-3) 40 | - [chapter-4](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-4) 41 | - [chapter-4.1](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-4.1) 42 | - [chapter-4.2](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-4.2) 43 | - [chapter-4.3](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-4.3) 44 | - [chapter-5](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-5) 45 | - [chapter-5.2](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-5.2) 46 | - [chapter-5.3](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-5.3) 47 | - [chapter-5.4](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-5.4) 48 | - [chapter-6](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-6) 49 | - [chapter-6.1](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-6.1) 50 | - [chapter-6.2](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-6.2) 51 | - [chapter-7](https://github.com/hwasurr/graphql-book-fullstack-project/tree/chapter-7) 52 | 53 | 4. 이제 구성된 프로젝트에서 작업을 시작할 수 있습니다. 본인만의 브랜치를 따로 만들고 싶은 경우 `git checkout -b <브랜치명>`명령 을 통해 새로운 브랜치를 만들어 진행할 수 있습니다. 54 | 55 | 문제가 있는 경우, issue 게시판을 활용해주세요. 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-book-fullstack-project", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "@typescript-eslint/eslint-plugin": "^4.28.3", 9 | "@typescript-eslint/parser": "^4.28.3", 10 | "eslint": "^7.31.0", 11 | "eslint-config-airbnb": "^18.2.1", 12 | "eslint-config-prettier": "^8.3.0", 13 | "eslint-plugin-import": "^2.23.4", 14 | "eslint-plugin-jsx-a11y": "^6.4.1", 15 | "eslint-plugin-prettier": "^3.4.0", 16 | "eslint-plugin-react": "^7.24.0", 17 | "eslint-plugin-react-hooks": "^4.2.0", 18 | "prettier": "^2.3.2", 19 | "typescript": "^4.3.5" 20 | } 21 | } -------------------------------------------------------------------------------- /project/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "dev": "nodemon --watch *.ts --exec ts-node src/index.ts", 9 | "build": "tsc", 10 | "bundle": "zip server.zip -r dist package.json", 11 | "start": "node dist/index.js" 12 | }, 13 | "dependencies": { 14 | "apollo-server-core": "^3.0.1", 15 | "apollo-server-express": "^3.0.1", 16 | "argon2": "^0.28.2", 17 | "class-validator": "^0.13.1", 18 | "cookie-parser": "^1.4.6", 19 | "dataloader": "^2.0.0", 20 | "dotenv": "^16.0.0", 21 | "express": "^4.17.1", 22 | "graphql": "^15.5.1", 23 | "graphql-subscriptions": "^2.0.0", 24 | "graphql-upload": "^13.0.0", 25 | "ioredis": "^4.28.0", 26 | "jsonwebtoken": "^8.5.1", 27 | "mysql2": "^2.3.0", 28 | "nanoid": "^3.1.30", 29 | "reflect-metadata": "^0.1.13", 30 | "subscriptions-transport-ws": "^0.11.0", 31 | "type-graphql": "^1.1.1", 32 | "typeorm": "^0.2.37", 33 | "typescript": "^4.3.5" 34 | }, 35 | "devDependencies": { 36 | "@types/cookie-parser": "^1.4.2", 37 | "@types/graphql-upload": "^8.0.7", 38 | "@types/ioredis": "^4.28.1", 39 | "@types/jsonwebtoken": "^8.5.5", 40 | "@types/nanoid": "^3.0.0", 41 | "nodemon": "^2.0.12", 42 | "ts-node": "^10.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /project/server/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/project/server/public/.gitkeep -------------------------------------------------------------------------------- /project/server/public/1hwasurr-animoji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/project/server/public/1hwasurr-animoji.png -------------------------------------------------------------------------------- /project/server/src/apollo/createApolloServer.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServerPluginLandingPageLocalDefault } from 'apollo-server-core'; 2 | import { ApolloServer } from 'apollo-server-express'; 3 | import { Request, Response } from 'express'; 4 | import { GraphQLSchema } from 'graphql'; 5 | import { createCutVoteLoader } from '../dataloaders/cutVoteLoader'; 6 | import redis from '../redis/redis-client'; 7 | import { 8 | JwtVerifiedUser, 9 | verifyAccessTokenFromReqHeaders, 10 | } from '../utils/jwt-auth'; 11 | 12 | export interface MyContext { 13 | req: Request; 14 | res: Response; 15 | verifiedUser: JwtVerifiedUser; 16 | redis: typeof redis; 17 | cutVoteLoader: ReturnType; 18 | } 19 | 20 | const createApolloServer = async ( 21 | schema: GraphQLSchema, 22 | ): Promise => { 23 | return new ApolloServer({ 24 | schema, 25 | plugins: [ApolloServerPluginLandingPageLocalDefault()], 26 | context: ({ req, res }) => { 27 | // 액세스 토큰 검증 28 | const verifed = verifyAccessTokenFromReqHeaders(req.headers); 29 | return { 30 | req, 31 | res, 32 | verifiedUser: verifed, 33 | redis, 34 | cutVoteLoader: createCutVoteLoader(), 35 | }; 36 | }, 37 | }); 38 | }; 39 | 40 | export default createApolloServer; 41 | -------------------------------------------------------------------------------- /project/server/src/apollo/createSchema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | import { PubSub } from 'graphql-subscriptions'; 3 | import { buildSchema } from 'type-graphql'; 4 | import { CutResolver } from '../resolvers/Cut'; 5 | import { CutReviewResolver } from '../resolvers/CutReview'; 6 | import { FilmResolver } from '../resolvers/Film'; 7 | import { NotificationResolver } from '../resolvers/Notification'; 8 | import { UserResolver } from '../resolvers/User'; 9 | 10 | export const createSchema = async (): Promise => { 11 | return buildSchema({ 12 | resolvers: [ 13 | FilmResolver, 14 | CutResolver, 15 | UserResolver, 16 | CutReviewResolver, 17 | NotificationResolver, 18 | ], 19 | pubSub: new PubSub(), 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /project/server/src/apollo/createSubscriptionServer.ts: -------------------------------------------------------------------------------- 1 | import { execute, GraphQLSchema, subscribe } from 'graphql'; 2 | import http from 'http'; 3 | import { SubscriptionServer } from 'subscriptions-transport-ws'; 4 | import { JwtVerifiedUser, verifyAccessToken } from '../utils/jwt-auth'; 5 | 6 | export interface MySubscriptionContext { 7 | verifiedUser: JwtVerifiedUser | null; 8 | } 9 | 10 | export const createSubscriptionServer = async ( 11 | schema: GraphQLSchema, 12 | server: http.Server, 13 | ): Promise => { 14 | return SubscriptionServer.create( 15 | { 16 | schema, 17 | execute, 18 | subscribe, 19 | onConnect: (connectionParams: any): MySubscriptionContext => { 20 | // WebSocketLink.connectionParams 반환값이 connectionParams로 전달되며 21 | // 이 함수의 반환값이 SubscriptionFilter의 context 로 전달됩니다. 22 | const accessToken = connectionParams.Authorization.split(' ')[1]; 23 | return { verifiedUser: verifyAccessToken(accessToken) }; 24 | }, 25 | onDisconnect: () => { 26 | console.log('disconnected'); 27 | }, 28 | }, 29 | { server, path: '/graphql' }, 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /project/server/src/dataloaders/cutVoteLoader.ts: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import { In } from 'typeorm'; 3 | import { Cut } from '../entities/Cut'; 4 | import { CutVote } from '../entities/CutVote'; 5 | 6 | type CutVoteKey = { 7 | cutId: Cut['id']; 8 | }; 9 | export const createCutVoteLoader = (): DataLoader => { 10 | return new DataLoader(async (keys) => { 11 | const cutIds = keys.map((key) => key.cutId); 12 | const votes = await CutVote.find({ where: { cutId: In(cutIds) } }); 13 | const result = keys.map((key) => 14 | votes.filter((vote) => vote.cutId === key.cutId), 15 | ); 16 | return result; 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /project/server/src/db/db-client.ts: -------------------------------------------------------------------------------- 1 | import { Connection, createConnection } from 'typeorm'; 2 | import { CutReview } from '../entities/CutReview'; 3 | import { CutVote } from '../entities/CutVote'; 4 | import Notification from '../entities/Notification'; 5 | import User from '../entities/User'; 6 | 7 | export const createDB = async (): Promise => 8 | createConnection({ 9 | type: 'mysql', 10 | host: process.env.DB_HOST || 'localhost', 11 | port: Number(process.env.DB_PORT) || 3306, 12 | database: process.env.DB_DATABASE || 'ghibli_graphql', 13 | username: process.env.DB_USERNAME || 'root', 14 | password: process.env.DB_PASSWORD || 'qwer1234', 15 | logging: !(process.env.NODE_ENV === 'production'), 16 | synchronize: true, // entities에 명시된 데이터 모델들을 DB에 자동으로 동기화합니다. 17 | entities: [User, CutVote, CutReview, Notification], // entities 폴더의 모든 데이터 모델이 위치해야 합니다. 18 | }); 19 | -------------------------------------------------------------------------------- /project/server/src/entities/Cut.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from 'type-graphql'; 2 | 3 | @ObjectType() 4 | export class Cut { 5 | @Field(() => Int, { description: '명장면 고유 아이디' }) 6 | id: number; 7 | 8 | @Field({ description: '명장면 사진 주소' }) 9 | src: string; 10 | 11 | @Field(() => Int, { description: '영화 아이디' }) 12 | filmId: number; 13 | } 14 | -------------------------------------------------------------------------------- /project/server/src/entities/CutReview.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from 'type-graphql'; 2 | import { 3 | BaseEntity, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | RelationId, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import User from './User'; 13 | 14 | @ObjectType() 15 | @Entity() 16 | export class CutReview extends BaseEntity { 17 | @PrimaryGeneratedColumn() 18 | @Field(() => Int) 19 | id: number; 20 | 21 | @Field({ description: '감상평 내용' }) 22 | @Column({ comment: '감상평 내용' }) 23 | contents: string; 24 | 25 | @Field(() => Int, { description: '명장면 번호' }) 26 | @Column({ comment: '명장면 번호' }) 27 | cutId: number; 28 | 29 | @Field(() => User) 30 | @ManyToOne(() => User, (user) => user.cutReviews) 31 | user: User; 32 | 33 | @RelationId((cutReview: CutReview) => cutReview.user) 34 | userId: number; 35 | 36 | @Field(() => String, { description: '생성 일자' }) 37 | @CreateDateColumn({ comment: '생성 일자' }) 38 | createdAt: Date; 39 | 40 | @Field(() => String, { description: '수정 일자' }) 41 | @UpdateDateColumn({ comment: '수정 일자' }) 42 | updatedAt: Date; 43 | } 44 | -------------------------------------------------------------------------------- /project/server/src/entities/CutVote.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from 'type-graphql'; 2 | import { BaseEntity, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; 3 | import { Cut } from './Cut'; 4 | import User from './User'; 5 | 6 | @Entity() 7 | @ObjectType() 8 | export class CutVote extends BaseEntity { 9 | @PrimaryColumn() 10 | @Field(() => Int) 11 | userId: number; 12 | 13 | @PrimaryColumn() 14 | @Field(() => Int) 15 | cutId: number; 16 | 17 | @Field(() => Cut) 18 | cut: Cut; 19 | 20 | @Field(() => User) 21 | @ManyToOne(() => User, (user) => user.cutVotes) 22 | user: User; 23 | } 24 | -------------------------------------------------------------------------------- /project/server/src/entities/Director.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from 'type-graphql'; 2 | 3 | @ObjectType() 4 | export class Director { 5 | @Field(() => Int) 6 | id: number; 7 | 8 | @Field(() => String) 9 | name: string; 10 | } 11 | -------------------------------------------------------------------------------- /project/server/src/entities/Film.ts: -------------------------------------------------------------------------------- 1 | // project/server/src/entities/Film.ts 2 | 3 | import { Field, Int, ObjectType } from 'type-graphql'; 4 | 5 | @ObjectType() 6 | export class Film { 7 | @Field(() => Int, { description: '영화 고유 아이디' }) 8 | id: number; 9 | 10 | @Field({ description: '영화 제목' }) 11 | title: string; 12 | 13 | @Field({ nullable: true, description: '영화 부제목' }) 14 | subtitle?: string; 15 | 16 | @Field({ description: '영화 장르' }) 17 | genre: string; 18 | 19 | @Field({ description: '영화 러닝 타임, minute' }) 20 | runningTime: number; 21 | 22 | @Field({ description: '영화 줄거리 및 설명' }) 23 | description: string; 24 | 25 | @Field(() => Int, { description: '제작자 고유 아이디' }) 26 | director_id: number; 27 | 28 | @Field({ description: '포스터 이미지 URL' }) 29 | posterImg: string; 30 | 31 | @Field({ description: '개봉일' }) 32 | release: string; 33 | } 34 | -------------------------------------------------------------------------------- /project/server/src/entities/Notification.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from 'type-graphql'; 2 | import { 3 | BaseEntity, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import User from './User'; 12 | 13 | @ObjectType() 14 | @Entity() 15 | export default class Notification extends BaseEntity { 16 | @Field(() => Int) 17 | @PrimaryGeneratedColumn() 18 | id!: number; 19 | 20 | @Field() 21 | @Column({ type: 'varchar' }) 22 | text: string; 23 | 24 | @Field(() => String) @CreateDateColumn() createdAt: Date; 25 | 26 | @Field(() => String) @UpdateDateColumn() updatedAt: Date; 27 | 28 | @Field() @Column() userId!: number; 29 | 30 | @ManyToOne(() => User, (user) => user.notifications) 31 | user: User; 32 | } 33 | -------------------------------------------------------------------------------- /project/server/src/entities/User.ts: -------------------------------------------------------------------------------- 1 | // project/server/src/entities/User.ts 2 | import { Field, Int, ObjectType } from 'type-graphql'; 3 | import { 4 | BaseEntity, 5 | Column, 6 | CreateDateColumn, 7 | Entity, 8 | OneToMany, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import { CutReview } from './CutReview'; 13 | import { CutVote } from './CutVote'; 14 | import Notification from './Notification'; 15 | 16 | @ObjectType() 17 | @Entity() 18 | export default class User extends BaseEntity { 19 | @Field(() => Int) 20 | @PrimaryGeneratedColumn() 21 | id!: number; 22 | 23 | @Field({ description: '유저 이름' }) 24 | @Column({ comment: '유저 이름' }) 25 | username: string; 26 | 27 | @Field({ description: '유저 이메일' }) 28 | @Column({ unique: true, comment: '유저 이메일' }) 29 | email: string; 30 | 31 | @Column({ comment: '비밀번호' }) 32 | password: string; 33 | 34 | @Column({ comment: '프로필 사진 경로', nullable: true }) 35 | @Field({ description: '프로필 사진 경로', nullable: true }) 36 | profileImage: string; 37 | 38 | @Field(() => String, { description: '생성 일자' }) 39 | @CreateDateColumn({ comment: '생성 일자' }) 40 | createdAt: Date; 41 | 42 | @Field(() => String, { description: '업데이트 일자' }) 43 | @UpdateDateColumn({ comment: '업데이트 일자' }) 44 | updatedAt: Date; 45 | 46 | @OneToMany(() => CutVote, (cutVote) => cutVote.user) 47 | cutVotes: CutVote[]; 48 | 49 | @OneToMany(() => CutReview, (cutReview) => cutReview.user) 50 | cutReviews: CutReview[]; 51 | 52 | @OneToMany(() => Notification, (noti) => noti.user) 53 | notifications: Notification[]; 54 | } 55 | -------------------------------------------------------------------------------- /project/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import cookieParser from 'cookie-parser'; 2 | import dotenv from 'dotenv'; 3 | import express from 'express'; 4 | import { graphqlUploadExpress } from 'graphql-upload'; 5 | import http from 'http'; 6 | import 'reflect-metadata'; 7 | import createApolloServer from './apollo/createApolloServer'; 8 | import { createSchema } from './apollo/createSchema'; 9 | import { createSubscriptionServer } from './apollo/createSubscriptionServer'; 10 | import { createDB } from './db/db-client'; 11 | 12 | dotenv.config(); 13 | 14 | async function main() { 15 | await createDB(); 16 | const app = express(); 17 | app.use(express.static('public')); 18 | app.use(cookieParser()); 19 | app.use(graphqlUploadExpress({ maxFileSize: 1024 * 1000 * 5, maxFiles: 1 })); 20 | app.get('/', (req, res) => { 21 | res.status(200).send(); // for healthcheck 22 | }); 23 | const httpServer = http.createServer(app); 24 | 25 | const schema = await createSchema(); 26 | await createSubscriptionServer(schema, httpServer); 27 | const apolloServer = await createApolloServer(schema); 28 | await apolloServer.start(); 29 | apolloServer.applyMiddleware({ 30 | app, 31 | cors: { 32 | origin: [ 33 | 'http://localhost:3000', 34 | 'https://studio.apollographql.com', 35 | process.env.FRONT_ORIGIN || '', 36 | ], 37 | credentials: true, 38 | }, 39 | }); 40 | 41 | httpServer.listen(process.env.PORT || 4000, () => { 42 | if (process.env.NODE_ENV !== 'production') { 43 | console.log(` 44 | server started on => http://localhost:4000 45 | apollo studio => http://localhost:4000/graphql 46 | `); 47 | } else { 48 | console.log(` 49 | Production server Started... 50 | `); 51 | } 52 | }); 53 | } 54 | 55 | main().catch((err) => console.error(err)); 56 | -------------------------------------------------------------------------------- /project/server/src/middlewares/isAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { MiddlewareFn } from 'type-graphql'; 3 | import { MyContext } from '../apollo/createApolloServer'; 4 | import { verifyAccessToken } from '../utils/jwt-auth'; 5 | 6 | export const isAuthenticated: MiddlewareFn = async ( 7 | { context }, 8 | next, 9 | ) => { 10 | const { authorization } = context.req.headers; 11 | if (!authorization) throw new AuthenticationError('unauthenticated'); 12 | const accessToken = authorization.split(' ')[1]; 13 | verifyAccessToken(accessToken); 14 | if (!context.verifiedUser) throw new AuthenticationError('unauthenticated'); 15 | 16 | return next(); 17 | }; 18 | -------------------------------------------------------------------------------- /project/server/src/redis/redis-client.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const redis = new Redis({ 4 | host: process.env.REDIS_HOST || 'localhost', 5 | port: Number(process.env.REDIS_PORT) || 6379, 6 | }); 7 | 8 | export default redis; 9 | -------------------------------------------------------------------------------- /project/server/src/resolvers/Cut.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | FieldResolver, 5 | Int, 6 | Mutation, 7 | Query, 8 | Resolver, 9 | Root, 10 | UseMiddleware, 11 | } from 'type-graphql'; 12 | import { Cut } from '../entities/Cut'; 13 | import { Film } from '../entities/Film'; 14 | import ghibliData from '../data/ghibli'; 15 | import { isAuthenticated } from '../middlewares/isAuthenticated'; 16 | import { MyContext } from '../apollo/createApolloServer'; 17 | import { CutVote } from '../entities/CutVote'; 18 | 19 | @Resolver(Cut) 20 | export class CutResolver { 21 | @Query(() => [Cut]) 22 | cuts(@Arg('filmId', () => Int) filmId: Film['id']): Cut[] { 23 | return ghibliData.cuts.filter((x) => x.filmId === filmId); 24 | } 25 | 26 | @Query(() => Cut, { nullable: true }) 27 | cut(@Arg('cutId', () => Int) cutId: number): Cut | undefined { 28 | return ghibliData.cuts.find((x) => x.id === cutId); 29 | } 30 | 31 | @FieldResolver(() => Film, { nullable: true }) 32 | film(@Root() cut: Cut): Film | undefined { 33 | return ghibliData.films.find((film) => film.id === cut.filmId); 34 | } 35 | 36 | @Mutation(() => Boolean) 37 | @UseMiddleware(isAuthenticated) 38 | async vote( 39 | @Arg('cutId', () => Int) cutId: number, 40 | @Ctx() { verifiedUser }: MyContext, 41 | ): Promise { 42 | if (verifiedUser) { 43 | const { userId } = verifiedUser; 44 | const alreadyVoted = await CutVote.findOne({ 45 | where: { 46 | cutId, 47 | userId, 48 | }, 49 | }); 50 | if (alreadyVoted) { 51 | await alreadyVoted.remove(); 52 | return true; 53 | } 54 | const vote = CutVote.create({ cutId, userId }); 55 | await vote.save(); 56 | return true; 57 | } 58 | return false; 59 | } 60 | 61 | @FieldResolver(() => Int) 62 | async votesCount( 63 | @Root() cut: Cut, 64 | @Ctx() { cutVoteLoader }: MyContext, 65 | ): Promise { 66 | // const count = await CutVote.count({ where: { cutId: cut.id } }); 67 | // return count; 68 | const cutVotes = await cutVoteLoader.load({ cutId: cut.id }); 69 | return cutVotes.length; 70 | } 71 | 72 | @FieldResolver(() => Boolean) 73 | async isVoted( 74 | @Root() cut: Cut, 75 | @Ctx() { cutVoteLoader, verifiedUser }: MyContext, 76 | ): Promise { 77 | if (verifiedUser) { 78 | const votes = await cutVoteLoader.load({ cutId: cut.id }); 79 | if (votes.some((vote) => vote.userId === verifiedUser.userId)) 80 | return true; 81 | return false; 82 | } 83 | return false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /project/server/src/resolvers/CutReview.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsString } from 'class-validator'; 2 | import { 3 | Arg, 4 | Args, 5 | ArgsType, 6 | Ctx, 7 | Field, 8 | FieldResolver, 9 | InputType, 10 | Int, 11 | Mutation, 12 | Query, 13 | Resolver, 14 | ResolverInterface, 15 | Root, 16 | UseMiddleware, 17 | } from 'type-graphql'; 18 | import { Not } from 'typeorm'; 19 | import { MyContext } from '../apollo/createApolloServer'; 20 | import { CutReview } from '../entities/CutReview'; 21 | import User from '../entities/User'; 22 | import { isAuthenticated } from '../middlewares/isAuthenticated'; 23 | 24 | @InputType() 25 | class CreateOrUpdateCutReviewInput { 26 | @Field(() => Int, { description: '명장면 번호' }) 27 | @IsInt() 28 | cutId: number; 29 | 30 | @Field({ description: '감상평 내용' }) 31 | @IsString() 32 | contents: string; 33 | } 34 | 35 | @ArgsType() 36 | class PaginationArgs { 37 | @Field(() => Int, { defaultValue: 2 }) 38 | take: number; 39 | 40 | @Field(() => Int, { nullable: true }) 41 | skip?: number; 42 | 43 | @Field(() => Int) cutId: number; 44 | } 45 | 46 | @Resolver(CutReview) 47 | export class CutReviewResolver implements ResolverInterface { 48 | @Mutation(() => CutReview, { nullable: true }) 49 | @UseMiddleware(isAuthenticated) 50 | async createOrUpdateCutReview( 51 | @Arg('cutReviewInput') cutReviewInput: CreateOrUpdateCutReviewInput, 52 | @Ctx() { verifiedUser }: MyContext, 53 | ): Promise { 54 | if (!verifiedUser) return null; 55 | const { contents, cutId } = cutReviewInput; 56 | // cutId에 대한 기존 감상평 조회 57 | const prevCutReview = await CutReview.findOne({ 58 | where: { cutId, user: { id: verifiedUser.userId } }, 59 | }); 60 | 61 | // cutId에 대한 기존 감상평 있는 경우 62 | if (prevCutReview) { 63 | prevCutReview.contents = contents; 64 | return prevCutReview.save(); 65 | } 66 | // cutId에 대한 기존 감상평 없는 경우 67 | const cutReview = CutReview.create({ 68 | contents: cutReviewInput.contents, 69 | cutId: cutReviewInput.cutId, 70 | user: { 71 | id: verifiedUser.userId, 72 | }, 73 | }); 74 | return cutReview.save(); 75 | } 76 | 77 | // 필드리졸버 User 78 | @FieldResolver(() => User) 79 | async user(@Root() cutReview: CutReview): Promise { 80 | return (await User.findOne(cutReview.userId))!; 81 | } 82 | 83 | @FieldResolver(() => Boolean) 84 | isMine( 85 | @Root() cutReview: CutReview, 86 | @Ctx() { verifiedUser }: MyContext, 87 | ): boolean { 88 | if (!verifiedUser) return false; 89 | return cutReview.userId === verifiedUser.userId; 90 | } 91 | 92 | @Query(() => [CutReview]) 93 | async cutReviews( 94 | @Args() { take, skip, cutId }: PaginationArgs, 95 | @Ctx() { verifiedUser }: MyContext, 96 | ): Promise { 97 | let realTake = 2; 98 | let reviewHistory: CutReview | undefined; 99 | if (verifiedUser && verifiedUser.userId) { 100 | reviewHistory = await CutReview.findOne({ 101 | where: { user: { id: verifiedUser.userId }, cutId }, 102 | }); 103 | } 104 | if (reviewHistory) { 105 | realTake = Math.min(take, 1); 106 | } 107 | const reviews = await CutReview.find({ 108 | where: reviewHistory 109 | ? { 110 | cutId, 111 | id: Not(reviewHistory.id), 112 | } 113 | : { cutId }, 114 | skip, 115 | take: realTake, 116 | order: { createdAt: 'DESC' }, 117 | }); 118 | 119 | if (reviewHistory) return [reviewHistory, ...reviews]; 120 | return reviews; 121 | } 122 | 123 | @Mutation(() => Boolean) 124 | @UseMiddleware(isAuthenticated) 125 | async deleteReview( 126 | @Arg('id', () => Int) id: number, 127 | @Ctx() { verifiedUser }: MyContext, 128 | ): Promise { 129 | const result = await CutReview.delete({ 130 | id, 131 | user: { id: verifiedUser.userId }, 132 | }); 133 | if (result.affected && result.affected > 0) return true; 134 | return false; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /project/server/src/resolvers/Film.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Field, 4 | FieldResolver, 5 | Int, 6 | ObjectType, 7 | Query, 8 | Resolver, 9 | Root, 10 | } from 'type-graphql'; 11 | import ghibliData from '../data/ghibli'; 12 | import { Director } from '../entities/Director'; 13 | import { Film } from '../entities/Film'; 14 | 15 | // 페이지처리된 영화목록 반환 오브젝트 타입 16 | @ObjectType() 17 | class PaginatedFilms { 18 | @Field(() => [Film]) 19 | films: Film[]; 20 | 21 | @Field(() => Int, { nullable: true }) 22 | cursor?: Film['id'] | null; 23 | } 24 | 25 | @Resolver(Film) 26 | export class FilmResolver { 27 | @Query(() => PaginatedFilms) 28 | films( 29 | @Arg('limit', () => Int, { nullable: true, defaultValue: 6 }) limit: number, 30 | @Arg('cursor', () => Int, { nullable: true, defaultValue: 1 }) 31 | cursor: Film['id'], 32 | ): PaginatedFilms { 33 | // 너무 많은 limit값은 6으로 제한 34 | const realLimit = Math.min(6, limit); 35 | 36 | // 커서가 없는 경우 빈 배열 전송 37 | if (!cursor) return { films: [] }; 38 | 39 | const cursorDataIndex = ghibliData.films.findIndex((f) => f.id === cursor); 40 | // 올바르지 않은 커서인 경우 초기값 전송 41 | if (cursorDataIndex === -1) return { films: [] }; 42 | 43 | const result = ghibliData.films.slice( 44 | cursorDataIndex, 45 | cursorDataIndex + realLimit, 46 | ); 47 | // 다음 커서 생성 48 | const nextCursor = result[result.length - 1].id + 1; 49 | // 다음 커서가 유효한지 확인 50 | const hasNext = ghibliData.films.findIndex((f) => f.id === nextCursor) > -1; 51 | 52 | return { 53 | cursor: hasNext ? nextCursor : null, 54 | films: result, 55 | }; 56 | } 57 | 58 | @FieldResolver(() => Director) 59 | director(@Root() parentFilm: Film): Director | undefined { 60 | return ghibliData.directors.find((dr) => dr.id === parentFilm.director_id); 61 | } 62 | 63 | @Query(() => Film, { nullable: true }) 64 | film(@Arg('filmId', () => Int) filmId: number): Film | undefined { 65 | return ghibliData.films.find((x) => x.id === filmId); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /project/server/src/resolvers/Notification.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | Int, 5 | Mutation, 6 | Publisher, 7 | PubSub, 8 | Query, 9 | Resolver, 10 | ResolverFilterData, 11 | Root, 12 | Subscription, 13 | UseMiddleware, 14 | } from 'type-graphql'; 15 | import { MyContext } from '../apollo/createApolloServer'; 16 | import { MySubscriptionContext } from '../apollo/createSubscriptionServer'; 17 | import Notification from '../entities/Notification'; 18 | import { isAuthenticated } from '../middlewares/isAuthenticated'; 19 | 20 | @Resolver(Notification) 21 | export class NotificationResolver { 22 | @UseMiddleware(isAuthenticated) 23 | @Query(() => [Notification], { 24 | description: '세션에 해당되는 유저의 모든 알림을 가져옵니다.', 25 | }) 26 | async notifications( 27 | @Ctx() { verifiedUser }: MyContext, 28 | ): Promise { 29 | const notifications = await Notification.find({ 30 | where: { userId: verifiedUser.userId }, 31 | order: { createdAt: 'DESC' }, 32 | }); 33 | return notifications; 34 | } 35 | 36 | @UseMiddleware(isAuthenticated) 37 | @Mutation(() => Notification) 38 | async createNotification( 39 | @Arg('userId', () => Int) userId: number, 40 | @Arg('text') text: string, 41 | @PubSub('NOTIFICATION_CREATED') publish: Publisher, 42 | ): Promise { 43 | const newNoti = await Notification.create({ 44 | text, 45 | userId, 46 | }).save(); 47 | await publish(newNoti); 48 | return newNoti; 49 | } 50 | 51 | @Subscription({ 52 | topics: 'NOTIFICATION_CREATED', 53 | // 자기 자신에게 온 알림이 생성되었을 때만 실행되어야 함. 54 | filter: ({ 55 | payload, 56 | context, 57 | }: ResolverFilterData) => { 58 | const { verifiedUser } = context; 59 | if (verifiedUser && payload && payload.userId === verifiedUser.userId) { 60 | return true; 61 | } 62 | return false; 63 | }, 64 | }) 65 | newNotification(@Root() notificationPayload: Notification): Notification { 66 | return notificationPayload; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /project/server/src/resolvers/User.ts: -------------------------------------------------------------------------------- 1 | import argon2 from 'argon2'; 2 | import { IsEmail, IsString } from 'class-validator'; 3 | import { createWriteStream } from 'fs'; 4 | import { FileUpload, GraphQLUpload } from 'graphql-upload'; 5 | import jwt from 'jsonwebtoken'; 6 | import { 7 | Arg, 8 | Ctx, 9 | Field, 10 | InputType, 11 | Mutation, 12 | ObjectType, 13 | Query, 14 | Resolver, 15 | UseMiddleware, 16 | } from 'type-graphql'; 17 | import { MyContext } from '../apollo/createApolloServer'; 18 | import User from '../entities/User'; 19 | import { isAuthenticated } from '../middlewares/isAuthenticated'; 20 | import { 21 | createAccessToken, 22 | createRefreshToken, 23 | REFRESH_JWT_SECRET_KEY, 24 | setRefreshTokenHeader, 25 | } from '../utils/jwt-auth'; 26 | 27 | @InputType() 28 | export class SignUpInput { 29 | @Field() @IsEmail() email: string; 30 | 31 | @Field() @IsString() username: string; 32 | 33 | @Field() @IsString() password: string; 34 | } 35 | 36 | @InputType({ description: '로그인 인풋 데이터' }) 37 | export class LoginInput { 38 | @Field() @IsString() emailOrUsername: string; 39 | 40 | @Field() @IsString() password: string; 41 | } 42 | 43 | @ObjectType({ description: '필드 에러 타입' }) 44 | class FieldError { 45 | @Field() field: string; 46 | 47 | @Field() message: string; 48 | } 49 | 50 | @ObjectType({ description: '로그인 반환 데이터' }) 51 | class LoginResponse { 52 | @Field(() => [FieldError], { nullable: true }) 53 | errors?: FieldError[]; 54 | 55 | @Field(() => User, { nullable: true }) 56 | user?: User; 57 | 58 | @Field({ nullable: true }) 59 | accessToken?: string; 60 | } 61 | 62 | @ObjectType({ description: '액세스 토큰 새로고침 반환 데이터' }) 63 | class RefreshAccessTokenResponse { 64 | @Field() accessToken: string; 65 | } 66 | 67 | @Resolver(User) 68 | export class UserResolver { 69 | @UseMiddleware(isAuthenticated) 70 | @Query(() => User, { nullable: true }) 71 | async me(@Ctx() ctx: MyContext): Promise { 72 | if (!ctx.verifiedUser) return undefined; 73 | return User.findOne({ where: { id: ctx.verifiedUser.userId } }); 74 | } 75 | 76 | @Mutation(() => User) 77 | async signUp(@Arg('signUpInput') signUpInput: SignUpInput): Promise { 78 | const { email, username, password } = signUpInput; 79 | 80 | const hashedPw = await argon2.hash(password); 81 | const newUser = User.create({ 82 | email, 83 | username, 84 | password: hashedPw, 85 | }); 86 | 87 | await User.insert(newUser); 88 | 89 | return newUser; 90 | } 91 | 92 | @Mutation(() => LoginResponse) 93 | public async login( 94 | @Arg('loginInput') loginInput: LoginInput, 95 | @Ctx() { res, redis }: MyContext, 96 | ): Promise { 97 | const { emailOrUsername, password } = loginInput; 98 | 99 | const user = await User.findOne({ 100 | where: [{ email: emailOrUsername }, { username: emailOrUsername }], 101 | }); 102 | if (!user) 103 | return { 104 | errors: [ 105 | { field: 'emailOrUsername', message: '해당하는 유저가 없습니다.' }, 106 | ], 107 | }; 108 | 109 | const isValid = await argon2.verify(user.password, password); 110 | if (!isValid) 111 | return { 112 | errors: [ 113 | { field: 'password', message: '비밀번호를 올바르게 입력해주세요.' }, 114 | ], 115 | }; 116 | 117 | // 액세스 토큰 발급 118 | const accessToken = createAccessToken(user); 119 | const refreshToken = createRefreshToken(user); 120 | // 리프레시 토큰 레디스 적재 121 | await redis.set(String(user.id), refreshToken); 122 | 123 | setRefreshTokenHeader(res, refreshToken); 124 | 125 | return { user, accessToken }; 126 | } 127 | 128 | @Mutation(() => Boolean) 129 | @UseMiddleware(isAuthenticated) 130 | async logout( 131 | @Ctx() { verifiedUser, res, redis }: MyContext, 132 | ): Promise { 133 | if (verifiedUser) { 134 | setRefreshTokenHeader(res, ''); // 리프레시 토큰 쿠키 제거 135 | await redis.del(String(verifiedUser.userId)); // 레디스 리프레시 토큰 제거 136 | } 137 | return true; 138 | } 139 | 140 | @Mutation(() => RefreshAccessTokenResponse, { nullable: true }) 141 | async refreshAccessToken( 142 | @Ctx() { req, redis, res }: MyContext, 143 | ): Promise { 144 | const refreshToken = req.cookies.refreshtoken; 145 | if (!refreshToken) return null; 146 | 147 | let tokenData: any = null; 148 | try { 149 | tokenData = jwt.verify(refreshToken, REFRESH_JWT_SECRET_KEY); 150 | } catch (e) { 151 | console.error(e); 152 | return null; 153 | } 154 | if (!tokenData) return null; 155 | 156 | // 레디스 상에 user.id 로 저장된 토큰 조회 157 | const storedRefreshToken = await redis.get(String(tokenData.userId)); 158 | if (!storedRefreshToken) return null; 159 | if (!(storedRefreshToken === refreshToken)) return null; 160 | 161 | const user = await User.findOne({ where: { id: tokenData.userId } }); 162 | if (!user) return null; 163 | 164 | const newAccessToken = createAccessToken(user); // 액세스토큰생성 165 | const newRefreshToken = createRefreshToken(user); // 리프레시토큰생성 166 | // 리프레시토큰 redis저장 167 | await redis.set(String(user.id), newRefreshToken); 168 | 169 | // 쿠키로 리프레시 토큰 전송 170 | setRefreshTokenHeader(res, newRefreshToken); 171 | 172 | return { accessToken: newAccessToken }; 173 | } 174 | 175 | @UseMiddleware(isAuthenticated) 176 | @Mutation(() => Boolean) 177 | async uploadProfileImage( 178 | @Ctx() { verifiedUser }: MyContext, 179 | @Arg('file', () => GraphQLUpload) 180 | { createReadStream, filename }: FileUpload, 181 | ): Promise { 182 | const realFileName = verifiedUser.userId + filename; 183 | const filePath = `public/${realFileName}`; 184 | 185 | return new Promise((resolve, reject) => 186 | createReadStream() 187 | .pipe(createWriteStream(filePath)) 188 | .on('finish', async () => { 189 | await User.update( 190 | { id: verifiedUser.userId }, 191 | { profileImage: realFileName }, 192 | ); 193 | return resolve(true); 194 | }) 195 | .on('error', () => reject(Error('file upload failed'))), 196 | ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /project/server/src/utils/jwt-auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { Response } from 'express'; 3 | import { IncomingHttpHeaders } from 'http'; 4 | import jwt from 'jsonwebtoken'; 5 | import User from '../entities/User'; 6 | 7 | export const DEFAULT_JWT_SECRET_KEY = 'secret-key'; 8 | export const REFRESH_JWT_SECRET_KEY = 'secret-key2'; 9 | 10 | export interface JwtVerifiedUser { 11 | userId: User['id']; 12 | } 13 | /** 액세스 토큰 발급 */ 14 | export const createAccessToken = (user: User): string => { 15 | const userData: JwtVerifiedUser = { userId: user.id }; 16 | const accessToken = jwt.sign( 17 | userData, 18 | process.env.JWT_SECRET_KEY || DEFAULT_JWT_SECRET_KEY, 19 | { expiresIn: '10m' }, 20 | ); 21 | return accessToken; 22 | }; 23 | 24 | /** 리프레시 토큰 발급 */ 25 | export const createRefreshToken = (user: User): string => { 26 | const userData: JwtVerifiedUser = { userId: user.id }; 27 | return jwt.sign( 28 | userData, 29 | process.env.JWT_REFRESH_SECRET_KEY || REFRESH_JWT_SECRET_KEY, 30 | { expiresIn: '14d' }, 31 | ); 32 | }; 33 | 34 | /** 액세스 토큰 검증 */ 35 | export const verifyAccessToken = ( 36 | accessToken?: string, 37 | ): JwtVerifiedUser | null => { 38 | if (!accessToken) return null; 39 | try { 40 | const verified = jwt.verify( 41 | accessToken, 42 | process.env.JWT_SECRET_KEY || DEFAULT_JWT_SECRET_KEY, 43 | ) as JwtVerifiedUser; 44 | return verified; 45 | } catch (err) { 46 | console.error('access_token expired: ', err.expiredAt); 47 | throw new AuthenticationError('access token expired'); 48 | } 49 | }; 50 | 51 | /** req.headers로부터 액세스 토큰 검증 */ 52 | export const verifyAccessTokenFromReqHeaders = ( 53 | headers: IncomingHttpHeaders, 54 | ): JwtVerifiedUser | null => { 55 | const { authorization } = headers; 56 | if (!authorization) return null; 57 | 58 | const accessToken = authorization.split(' ')[1]; 59 | try { 60 | return verifyAccessToken(accessToken); 61 | } catch { 62 | return null; 63 | } 64 | }; 65 | 66 | export const setRefreshTokenHeader = ( 67 | res: Response, 68 | refreshToken: string, 69 | ): void => { 70 | res.cookie('refreshtoken', refreshToken, { 71 | // 자바스크립트 코드로 접근 불가능하도록 72 | httpOnly: true, 73 | secure: process.env.NODE_ENV === 'production', 74 | sameSite: 'lax', 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /project/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "dom", 7 | "es6", 8 | "es2017", 9 | "esnext.asynciterable" 10 | ], 11 | "skipLibCheck": false, 12 | "sourceMap": true, 13 | "outDir": "./dist", 14 | "moduleResolution": "node", 15 | "removeComments": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "noImplicitThis": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "allowSyntheticDefaultImports": true, 23 | "esModuleInterop": true, 24 | "emitDecoratorMetadata": true, 25 | "experimentalDecorators": true, 26 | "resolveJsonModule": true, 27 | "baseUrl": "." 28 | }, 29 | "exclude": [ 30 | "node_modules" 31 | ], 32 | "include": [ 33 | "./**/*.ts" 34 | ] 35 | } -------------------------------------------------------------------------------- /project/web/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_HOST=http://localhost:4000/ 2 | REACT_APP_API_SUBSCRIPTION_HOST=ws://localhost:4000 3 | -------------------------------------------------------------------------------- /project/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /project/web/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with 2 | [Create React App](https://github.com/facebook/create-react-app). 3 | 4 | ## Available Scripts 5 | 6 | In the project directory, you can run: 7 | 8 | ### `yarn start` 9 | 10 | Runs the app in the development mode.
Open 11 | [http://localhost:3000](http://localhost:3000) to view it in the browser. 12 | 13 | The page will reload if you make edits.
You will also see any lint errors 14 | in the console. 15 | 16 | ### `yarn test` 17 | 18 | Launches the test runner in the interactive watch mode.
See the section 19 | about 20 | [running tests](https://facebook.github.io/create-react-app/docs/running-tests) 21 | for more information. 22 | 23 | ### `yarn build` 24 | 25 | Builds the app for production to the `build` folder.
It correctly bundles 26 | React in production mode and optimizes the build for the best performance. 27 | 28 | The build is minified and the filenames include the hashes.
Your app is 29 | ready to be deployed! 30 | 31 | See the section about 32 | [deployment](https://facebook.github.io/create-react-app/docs/deployment) for 33 | more information. 34 | 35 | ### `yarn eject` 36 | 37 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 38 | 39 | If you aren’t satisfied with the build tool and configuration choices, you can 40 | `eject` at any time. This command will remove the single build dependency from 41 | your project. 42 | 43 | Instead, it will copy all the configuration files and the transitive 44 | dependencies (webpack, Babel, ESLint, etc) right into your project so you have 45 | full control over them. All of the commands except `eject` will still work, but 46 | they will point to the copied scripts so you can tweak them. At this point 47 | you’re on your own. 48 | 49 | You don’t have to ever use `eject`. The curated feature set is suitable for 50 | small and middle deployments, and you shouldn’t feel obligated to use this 51 | feature. However we understand that this tool wouldn’t be useful if you couldn’t 52 | customize it when you are ready for it. 53 | 54 | ## Learn More 55 | 56 | You can learn more in the 57 | [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 58 | 59 | To learn React, check out the [React documentation](https://reactjs.org/). 60 | -------------------------------------------------------------------------------- /project/web/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "http://localhost:4000/graphql" 3 | documents: "src/**/*.graphql" 4 | generates: 5 | src/generated/graphql.tsx: 6 | plugins: 7 | - add: 8 | content: '/* eslint-disable */' 9 | - "typescript" 10 | - "typescript-operations" 11 | - "typescript-react-apollo" 12 | -------------------------------------------------------------------------------- /project/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/client": "^3.3.21", 7 | "@chakra-ui/icons": "^1.0.14", 8 | "@chakra-ui/react": "^1.0.0", 9 | "@emotion/react": "^11.0.0", 10 | "@emotion/styled": "^11.0.0", 11 | "@testing-library/jest-dom": "^5.9.0", 12 | "@testing-library/react": "^10.2.1", 13 | "@testing-library/user-event": "^12.0.2", 14 | "@types/jest": "^25.0.0", 15 | "@types/node": "^12.0.0", 16 | "@types/react": "^16.9.0", 17 | "@types/react-dom": "^16.9.0", 18 | "apollo-upload-client": "^17.0.0", 19 | "framer-motion": "^4.0.0", 20 | "graphql": "^15.5.1", 21 | "react": "^17.0.2", 22 | "react-dom": "^17.0.2", 23 | "react-hook-form": "^7.14.0", 24 | "react-icons": "^3.0.0", 25 | "react-lazyload": "^3.2.0", 26 | "react-router-dom": "^5.2.0", 27 | "react-scripts": "4.0.3", 28 | "react-waypoint": "^10.1.0", 29 | "typescript": "^4.3.5", 30 | "web-vitals": "^0.2.2" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test", 36 | "eject": "react-scripts eject", 37 | "codegen": "graphql-codegen --config codegen.yml" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "@graphql-codegen/add": "^2.0.2", 53 | "@graphql-codegen/cli": "^1.21.8", 54 | "@graphql-codegen/typescript": "^1.23.0", 55 | "@graphql-codegen/typescript-operations": "^1.18.4", 56 | "@graphql-codegen/typescript-react-apollo": "^2.3.1", 57 | "@types/apollo-upload-client": "^14.1.0", 58 | "@types/react-lazyload": "^3.1.1", 59 | "@types/react-router-dom": "^5.1.8" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /project/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/project/web/public/favicon.ico -------------------------------------------------------------------------------- /project/web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /project/web/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/project/web/public/logo192.png -------------------------------------------------------------------------------- /project/web/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwasurr/graphql-book-fullstack-project/f374c8e50187e4df1c7376f0ce350b5d08f5ffc8/project/web/public/logo512.png -------------------------------------------------------------------------------- /project/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /project/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /project/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloProvider } from '@apollo/client'; 2 | import { ChakraProvider, theme } from '@chakra-ui/react'; 3 | import { BrowserRouter, Route } from 'react-router-dom'; 4 | import { createApolloClient } from './apollo/createApolloClient'; 5 | import Film from './pages/Film'; 6 | import Main from './pages/Main'; 7 | import SignUp from './pages/SignUp'; 8 | import Login from './pages/Login'; 9 | 10 | const apolloClient = createApolloClient(); 11 | 12 | export const App: React.FC = () => { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /project/web/src/apollo/auth.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, NormalizedCacheObject, Operation } from '@apollo/client'; 2 | import { 3 | RefreshAccessTokenMutation, 4 | RefreshAccessTokenDocument, 5 | } from '../generated/graphql'; 6 | 7 | export const refreshAccessToken = ( 8 | _apolloClient: ApolloClient, 9 | operation: Operation, 10 | ): Promise => 11 | _apolloClient 12 | .mutate({ 13 | mutation: RefreshAccessTokenDocument, 14 | }) 15 | .then(({ data }) => { 16 | const newAccessToken = data?.refreshAccessToken?.accessToken; 17 | if (!newAccessToken) { 18 | localStorage.setItem('access_token', ''); 19 | return false; 20 | } 21 | localStorage.setItem('access_token', newAccessToken); 22 | const prevContext = operation.getContext(); 23 | operation.setContext({ 24 | headers: { 25 | ...prevContext.headers, 26 | authorization: `Bearer ${newAccessToken}`, 27 | }, 28 | }); 29 | return true; 30 | }) 31 | .catch(() => { 32 | localStorage.setItem('access_token', ''); 33 | return false; 34 | }); 35 | -------------------------------------------------------------------------------- /project/web/src/apollo/createApolloCache.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache } from '@apollo/client'; 2 | import { PaginatedFilms } from '../generated/graphql'; 3 | 4 | export const createApolloCache = (): InMemoryCache => { 5 | return new InMemoryCache({ 6 | typePolicies: { 7 | Query: { 8 | fields: { 9 | films: { 10 | keyArgs: false, 11 | merge: ( 12 | existing: PaginatedFilms | undefined, 13 | incoming: PaginatedFilms, 14 | ): PaginatedFilms => { 15 | return { 16 | cursor: incoming.cursor, 17 | films: existing 18 | ? [...existing.films, ...incoming.films] 19 | : incoming.films, 20 | }; 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /project/web/src/apollo/createApolloClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | from, 4 | fromPromise, 5 | NormalizedCacheObject, 6 | split, 7 | } from '@apollo/client'; 8 | import { setContext } from '@apollo/client/link/context'; 9 | import { onError } from '@apollo/client/link/error'; 10 | import { WebSocketLink } from '@apollo/client/link/ws'; 11 | import { getMainDefinition } from '@apollo/client/utilities'; 12 | import { createUploadLink } from 'apollo-upload-client'; 13 | import { refreshAccessToken } from './auth'; 14 | import { createApolloCache } from './createApolloCache'; 15 | 16 | let apolloClient: ApolloClient; 17 | 18 | const errorLink = onError( 19 | // eslint-disable-next-line consistent-return 20 | ({ graphQLErrors, networkError, operation, forward }) => { 21 | if (graphQLErrors) { 22 | if (graphQLErrors.find((err) => err.message === 'access token expired')) { 23 | return fromPromise(refreshAccessToken(apolloClient, operation)) 24 | .filter((result) => !!result) 25 | .flatMap(() => forward(operation)); 26 | } 27 | 28 | graphQLErrors.forEach(({ message, locations, path }) => 29 | // eslint-disable-next-line no-console 30 | console.log( 31 | `[GraphQL error]: -> ${operation.operationName} 32 | Message: ${message}, Query: ${path}, Location: ${JSON.stringify( 33 | locations, 34 | )}`, 35 | ), 36 | ); 37 | } 38 | 39 | if (networkError) { 40 | // eslint-disable-next-line no-console 41 | console.log(`[networkError]: -> ${operation.operationName} 42 | Message: ${networkError.message}`); 43 | } 44 | }, 45 | ); 46 | 47 | const authLink = setContext((request, prevContext) => { 48 | const accessToken = localStorage.getItem('access_token'); 49 | return { 50 | headers: { 51 | ...prevContext.headers, 52 | Authorization: accessToken ? `Bearer ${accessToken}` : '', 53 | }, 54 | }; 55 | }); 56 | 57 | const httpUploadLink = createUploadLink({ 58 | uri: `${process.env.REACT_APP_API_HOST}/graphql`, 59 | fetchOptions: { 60 | credentials: 'include', 61 | }, 62 | }); 63 | 64 | const wsLink = new WebSocketLink({ 65 | uri: `${process.env.REACT_APP_API_SUBSCRIPTION_HOST}/graphql`, 66 | options: { 67 | reconnect: true, 68 | connectionParams: () => { 69 | const accessToken = localStorage.getItem('access_token'); 70 | return { 71 | Authorization: accessToken ? `Bearer ${accessToken}` : '', 72 | }; 73 | }, 74 | }, 75 | }); 76 | 77 | const splitLink = split( 78 | ({ query }) => { 79 | const definition = getMainDefinition(query); 80 | return ( 81 | definition.kind === 'OperationDefinition' && 82 | definition.operation === 'subscription' 83 | ); 84 | }, 85 | from([wsLink]), 86 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 87 | from([authLink, errorLink, httpUploadLink as any]), 88 | ); 89 | 90 | export const createApolloClient = (): ApolloClient => { 91 | apolloClient = new ApolloClient({ 92 | cache: createApolloCache(), 93 | uri: `${process.env.REACT_APP_API_HOST}/graphql`, 94 | link: splitLink, 95 | }); 96 | return apolloClient; 97 | }; 98 | -------------------------------------------------------------------------------- /project/web/src/components/ColorModeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | useColorMode, 4 | useColorModeValue, 5 | IconButton, 6 | IconButtonProps, 7 | } from '@chakra-ui/react'; 8 | import { FaMoon, FaSun } from 'react-icons/fa'; 9 | 10 | type ColorModeSwitcherProps = Omit; 11 | 12 | export const ColorModeSwitcher: React.FC = (props) => { 13 | const { toggleColorMode } = useColorMode(); 14 | const text = useColorModeValue('dark', 'light'); 15 | const SwitchIcon = useColorModeValue(FaMoon, FaSun); 16 | 17 | return ( 18 | } 26 | aria-label={`Switch to ${text} mode`} 27 | {...props} 28 | /> 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /project/web/src/components/CommonLayout.tsx: -------------------------------------------------------------------------------- 1 | import { BackgroundProps, Box } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | import Navbar from './nav/Navbar'; 4 | 5 | interface CommonLayoutProps { 6 | bg?: BackgroundProps['bg']; 7 | children: React.ReactNode; 8 | } 9 | export default function CommonLayout({ 10 | children, 11 | bg, 12 | }: CommonLayoutProps): React.ReactElement { 13 | return ( 14 |
15 | 16 | 25 | {children} 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /project/web/src/components/auth/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import { 3 | Box, 4 | Button, 5 | Divider, 6 | FormControl, 7 | FormErrorMessage, 8 | FormLabel, 9 | Heading, 10 | Input, 11 | Stack, 12 | Text, 13 | useColorModeValue, 14 | } from '@chakra-ui/react'; 15 | import React from 'react'; 16 | import { useForm } from 'react-hook-form'; 17 | import { useHistory } from 'react-router-dom'; 18 | import { 19 | LoginMutationVariables, 20 | useLoginMutation, 21 | } from '../../generated/graphql'; 22 | 23 | export function RealLoginForm(): React.ReactElement { 24 | const { 25 | register, 26 | handleSubmit, 27 | formState: { errors }, 28 | setError, 29 | } = useForm(); 30 | 31 | const history = useHistory(); 32 | const [login, { loading }] = useLoginMutation(); 33 | const onSubmit = async (formData: LoginMutationVariables) => { 34 | const { data } = await login({ variables: formData }); 35 | if (data?.login.errors) { 36 | data.login.errors.forEach((err) => { 37 | const field = 'loginInput.'; 38 | setError((field + err.field) as Parameters[0], { 39 | message: err.message, 40 | }); 41 | }); 42 | } 43 | if (data && data.login.accessToken) { 44 | localStorage.setItem('access_token', data.login.accessToken); 45 | history.push('/'); 46 | } 47 | }; 48 | 49 | return ( 50 | 56 | 57 | 58 | 이메일 또는 아이디 59 | 66 | 67 | {errors.loginInput?.emailOrUsername && 68 | errors.loginInput.emailOrUsername.message} 69 | 70 | 71 | 72 | 73 | 암호 74 | 81 | 82 | {errors.loginInput?.password && errors.loginInput.password.message} 83 | 84 | 85 | 86 | 87 | 88 | 91 | 92 | 93 | ); 94 | } 95 | 96 | function LoginForm(): React.ReactElement { 97 | return ( 98 | 99 | 100 | 지브리 명장면 프로젝트 101 | 102 | 감상평과 좋아요를 눌러보세요! 103 | 104 | 105 | 106 | 107 | 108 | ); 109 | } 110 | 111 | export default LoginForm; 112 | -------------------------------------------------------------------------------- /project/web/src/components/auth/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | // project/web/src/components/auth/SignUpForm.tsx 3 | import { 4 | Box, 5 | Button, 6 | Divider, 7 | FormControl, 8 | FormErrorMessage, 9 | FormLabel, 10 | Heading, 11 | Input, 12 | Stack, 13 | Text, 14 | useColorModeValue, 15 | useToast, 16 | } from '@chakra-ui/react'; 17 | import { useForm } from 'react-hook-form'; 18 | import { useHistory } from 'react-router-dom'; 19 | import { 20 | SignUpMutationVariables, 21 | useSignUpMutation, 22 | } from '../../generated/graphql'; 23 | 24 | function SignUpRealForm() { 25 | const [signUp, { loading }] = useSignUpMutation(); 26 | const { 27 | register, 28 | handleSubmit, 29 | formState: { errors }, 30 | } = useForm(); 31 | const history = useHistory(); 32 | const toast = useToast(); 33 | 34 | const onSubmit = async (data: SignUpMutationVariables) => { 35 | const { signUpInput } = data; 36 | return signUp({ variables: { signUpInput } }) 37 | .then((res) => { 38 | if (res.data?.signUp) { 39 | toast({ title: '회원가입을 환영합니다!', status: 'success' }); 40 | history.push('/'); 41 | } else { 42 | toast({ 43 | title: '회원가입 도중 문제가 발생했습니다.', 44 | status: 'error', 45 | }); 46 | } 47 | }) 48 | .catch((err) => { 49 | toast({ title: '이메일 또는 아이디가 중복됩니다.', status: 'error' }); 50 | return err; 51 | }); 52 | }; 53 | 54 | return ( 55 | 56 | 57 | 이메일 58 | 71 | 72 | {errors.signUpInput?.email && errors.signUpInput.email.message} 73 | 74 | 75 | 76 | 77 | 아이디 78 | 85 | 86 | {errors.signUpInput?.username && errors.signUpInput.username.message} 87 | 88 | 89 | 90 | 91 | 암호 92 | 107 | 108 | {errors.signUpInput?.password && errors.signUpInput.password.message} 109 | 110 | 111 | 112 | 113 | 114 | 117 | 118 | ); 119 | } 120 | 121 | export default function SignUpForm(): React.ReactElement { 122 | return ( 123 | 124 | 125 | 지브리 명장면 프로젝트 126 | 127 | 가입을 환영합니다! 128 | 129 | 130 | 131 | 138 | 139 | 140 | 141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /project/web/src/components/film-cut/FilmCutDetail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AspectRatio, 3 | Box, 4 | Button, 5 | Center, 6 | Flex, 7 | Heading, 8 | HStack, 9 | Image, 10 | SimpleGrid, 11 | Text, 12 | useColorModeValue, 13 | useDisclosure, 14 | useToast, 15 | } from '@chakra-ui/react'; 16 | import { useMemo } from 'react'; 17 | import { FaHeart } from 'react-icons/fa'; 18 | import { 19 | CutDocument, 20 | CutQuery, 21 | CutQueryVariables, 22 | useMeQuery, 23 | useVoteMutation, 24 | } from '../../generated/graphql'; 25 | import { FilmCutReview } from './FilmCutReview'; 26 | import FilmCutReviewDeleteAlert from './FilmCutReviewDelete'; 27 | import { FilmCutReviewRegiModal } from './FilmCutReviewRegiModal'; 28 | 29 | interface FilmCutDetailProps { 30 | cutImg: string; 31 | cutId: number; 32 | isVoted?: boolean; 33 | votesCount?: number; 34 | reviews: CutQuery['cutReviews']; 35 | } 36 | export function FilmCutDetail({ 37 | cutImg, 38 | cutId, 39 | isVoted = false, 40 | votesCount = 0, 41 | reviews, 42 | }: FilmCutDetailProps): JSX.Element { 43 | const toast = useToast(); 44 | const voteButtonColor = useColorModeValue('gray.500', 'gray.400'); 45 | const [vote, { loading: voteLoading }] = useVoteMutation({ 46 | variables: { cutId }, 47 | update: (cache, fetchResult) => { 48 | // 'cut'Query 데이터 조회 49 | const currentCut = cache.readQuery({ 50 | query: CutDocument, 51 | variables: { cutId }, 52 | }); 53 | if (currentCut && currentCut.cut) { 54 | if (fetchResult.data?.vote) { 55 | // 'cut'Query 의 데이터를 재설정 56 | cache.writeQuery({ 57 | query: CutDocument, 58 | variables: { cutId: currentCut.cut.id }, 59 | data: { 60 | __typename: 'Query', 61 | ...currentCut, 62 | cut: { 63 | ...currentCut.cut, 64 | votesCount: isVoted 65 | ? currentCut.cut.votesCount - 1 66 | : currentCut.cut.votesCount + 1, 67 | isVoted: !isVoted, 68 | }, 69 | }, 70 | }); 71 | } 72 | } 73 | }, 74 | }); 75 | 76 | const accessToken = localStorage.getItem('access_token'); 77 | const { data: userData } = useMeQuery({ skip: !accessToken }); 78 | const isLoggedIn = useMemo(() => { 79 | if (accessToken) return userData?.me?.id; 80 | return false; 81 | }, [accessToken, userData?.me?.id]); 82 | 83 | const reviewRegiDialog = useDisclosure(); 84 | const deleteAlert = useDisclosure(); 85 | return ( 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | {cutId} 번째 사진 94 | 95 | 112 | 115 | 116 | 117 | 118 | {/* 감상 목록 */} 119 | 120 | {!reviews || reviews.length === 0 ? ( 121 |
122 | 제일 먼저 감상을 남겨보세요! 123 |
124 | ) : ( 125 | 126 | {reviews.slice(0, 2).map((review) => ( 127 | 135 | ))} 136 | 137 | )} 138 |
139 |
140 | 141 | 146 | review.isMine)} 148 | isOpen={deleteAlert.isOpen} 149 | onClose={deleteAlert.onClose} 150 | /> 151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /project/web/src/components/film-cut/FilmCutList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Image, 4 | LinkBox, 5 | LinkOverlay, 6 | SimpleGrid, 7 | Spinner, 8 | } from '@chakra-ui/react'; 9 | import LazyLoad from 'react-lazyload'; 10 | import { useCutsQuery } from '../../generated/graphql'; 11 | 12 | interface FilmCutListProps { 13 | filmId: number; 14 | onClick: (cutId: number) => void; 15 | } 16 | 17 | function FilmCutList({ 18 | filmId, 19 | onClick, 20 | }: FilmCutListProps): React.ReactElement { 21 | const { data, loading } = useCutsQuery({ variables: { filmId } }); 22 | 23 | if (loading) { 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | return ( 32 | 33 | {data?.cuts.map((cut) => ( 34 | 35 | 36 | 37 | onClick(cut.id)}> 38 | 39 | 40 | 41 | 42 | 43 | ))} 44 | 45 | ); 46 | } 47 | 48 | export default FilmCutList; 49 | -------------------------------------------------------------------------------- /project/web/src/components/film-cut/FilmCutModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Center, 3 | Modal, 4 | ModalBody, 5 | ModalCloseButton, 6 | ModalContent, 7 | ModalHeader, 8 | ModalOverlay, 9 | Spinner, 10 | useBreakpointValue, 11 | } from '@chakra-ui/react'; 12 | import React from 'react'; 13 | import { useCutQuery } from '../../generated/graphql'; 14 | import { FilmCutDetail } from './FilmCutDetail'; 15 | 16 | interface FilmCutModalProps { 17 | open: boolean; 18 | onClose: () => void; 19 | cutId: number; 20 | } 21 | 22 | function FilmCutModal({ 23 | open, 24 | onClose, 25 | cutId, 26 | }: FilmCutModalProps): React.ReactElement { 27 | const { loading, data } = useCutQuery({ 28 | variables: { cutId: Number(cutId) }, 29 | }); 30 | 31 | const modalSize = useBreakpointValue({ base: 'full', md: 'xl' }); 32 | 33 | return ( 34 | 41 | 42 | 43 | {data?.cut?.film?.title} 44 | 45 | 46 | {loading && ( 47 |
48 | 49 |
50 | )} 51 | {!loading && !data &&
데이터를 불러오지 못했습니다.
} 52 | {data && data.cut && ( 53 | 60 | )} 61 |
62 |
63 |
64 | ); 65 | } 66 | 67 | export default FilmCutModal; 68 | -------------------------------------------------------------------------------- /project/web/src/components/film-cut/FilmCutReview.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar, 3 | Box, 4 | Divider, 5 | Flex, 6 | HStack, 7 | IconButton, 8 | Text, 9 | Tooltip, 10 | } from '@chakra-ui/react'; 11 | import { MdDelete, MdEdit } from 'react-icons/md'; 12 | 13 | interface FilmCutReviewProps { 14 | author: string; 15 | isMine: boolean; 16 | contents: string; 17 | onEditClick: () => void; 18 | onDeleteClick: () => void; 19 | } 20 | export function FilmCutReview({ 21 | author, 22 | isMine, 23 | contents, 24 | onEditClick, 25 | onDeleteClick, 26 | }: FilmCutReviewProps): JSX.Element { 27 | return ( 28 | 29 | 30 | 31 | 32 | {author} 33 | 34 | {isMine && ( 35 | 36 | 37 | } 42 | onClick={onEditClick} 43 | /> 44 | 45 | 46 | } 51 | onClick={onDeleteClick} 52 | /> 53 | 54 | 55 | )} 56 | 57 | 58 | 59 | {contents} 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /project/web/src/components/film-cut/FilmCutReviewDelete.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogOverlay, 4 | AlertDialogContent, 5 | AlertDialogHeader, 6 | AlertDialogBody, 7 | AlertDialogFooter, 8 | Button, 9 | } from '@chakra-ui/react'; 10 | import React, { useRef } from 'react'; 11 | import { CutQuery, useDeleteCutReviewMutation } from '../../generated/graphql'; 12 | 13 | interface FilmCutReviewDeleteAlertProps { 14 | target?: CutQuery['cutReviews'][0]; 15 | isOpen: boolean; 16 | onClose: () => void; 17 | } 18 | function FilmCutReviewDeleteAlert({ 19 | target, 20 | isOpen, 21 | onClose, 22 | }: FilmCutReviewDeleteAlertProps): React.ReactElement { 23 | const cancelRef = useRef(null); 24 | const [deleteCutReview] = useDeleteCutReviewMutation(); 25 | async function handleDelete() { 26 | if (target) { 27 | await deleteCutReview({ 28 | variables: { id: target.id }, 29 | update: (cache) => { 30 | cache.evict({ id: `CutReview:${target.id}` }); 31 | }, 32 | }); 33 | onClose(); 34 | } 35 | } 36 | return ( 37 | 42 | 43 | 44 | 45 | 감상 삭제 46 | 47 | 48 | 49 | 감상을 삭제하시겠습니까? 되돌릴 수 없습니다. 50 | 51 | 52 | 53 | 56 | 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | export default FilmCutReviewDeleteAlert; 66 | -------------------------------------------------------------------------------- /project/web/src/components/film-cut/FilmCutReviewRegiModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ButtonGroup, 4 | FormControl, 5 | FormErrorMessage, 6 | Modal, 7 | ModalBody, 8 | ModalContent, 9 | ModalFooter, 10 | ModalHeader, 11 | ModalOverlay, 12 | Textarea, 13 | useToast, 14 | } from '@chakra-ui/react'; 15 | import { useForm } from 'react-hook-form'; 16 | import { 17 | CreateOrUpdateCutReviewMutationVariables as CutReviewVars, 18 | CutDocument, 19 | CutQuery, 20 | useCreateOrUpdateCutReviewMutation as useCreateCutReview, 21 | } from '../../generated/graphql'; 22 | 23 | export interface FilmCutReviewRegiModalProps { 24 | cutId: number; 25 | isOpen: boolean; 26 | onClose: () => void; 27 | } 28 | export function FilmCutReviewRegiModal({ 29 | cutId, 30 | isOpen, 31 | onClose, 32 | }: FilmCutReviewRegiModalProps): JSX.Element { 33 | const toast = useToast(); 34 | const [mutation, { loading }] = useCreateCutReview(); 35 | const { 36 | register, 37 | handleSubmit, 38 | formState: { errors }, 39 | } = useForm({ 40 | defaultValues: { 41 | cutReviewInput: { cutId }, 42 | }, 43 | }); 44 | function onSubmit(formData: CutReviewVars): void { 45 | mutation({ 46 | variables: formData, 47 | update: (cache, { data }) => { 48 | if (data && data.createOrUpdateCutReview) { 49 | const currentCut = cache.readQuery({ 50 | query: CutDocument, 51 | variables: { cutId }, 52 | }); 53 | if (currentCut) { 54 | const isEdited = currentCut.cutReviews 55 | .map((review) => review.id) 56 | .includes(data.createOrUpdateCutReview.id); 57 | if (isEdited) { 58 | cache.evict({ 59 | id: `CutReview:${data.createOrUpdateCutReview.id}`, 60 | }); 61 | } 62 | cache.writeQuery({ 63 | query: CutDocument, 64 | data: { 65 | ...currentCut, 66 | cutReviews: isEdited 67 | ? [...currentCut.cutReviews] 68 | : [ 69 | data.createOrUpdateCutReview, 70 | ...currentCut.cutReviews.slice(0, 1), 71 | ], 72 | }, 73 | variables: { cutId }, 74 | }); 75 | } 76 | } 77 | }, 78 | }) 79 | .then(onClose) 80 | .catch(() => { 81 | toast({ title: '감상평 등록 실패', status: 'error' }); 82 | }); 83 | } 84 | return ( 85 | 86 | 87 | 88 | 감상남기기 89 | 90 | 91 |