├── .gitignore ├── frontend ├── test │ └── .gitkeep ├── src │ ├── styles │ │ ├── reset.css │ │ ├── globals.css │ │ └── Home.module.css │ ├── components │ │ ├── atoms │ │ │ ├── Chart │ │ │ │ ├── styled.ts │ │ │ │ ├── Chart.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── Logo │ │ │ │ ├── logo.png │ │ │ │ ├── shortLogo.png │ │ │ │ ├── Logo.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── Button │ │ │ │ ├── user.png │ │ │ │ ├── logout.png │ │ │ │ ├── button.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── IconWithNumber │ │ │ │ ├── views.png │ │ │ │ ├── comments.png │ │ │ │ ├── styled.ts │ │ │ │ ├── IconWithNumber.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── ContentText │ │ │ │ ├── styled.ts │ │ │ │ ├── ContentText.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── HeaderText │ │ │ │ ├── styled.ts │ │ │ │ ├── HeaderText.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── TitleText │ │ │ │ ├── styled.ts │ │ │ │ ├── TitleText.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── SearchInput │ │ │ │ ├── styled.ts │ │ │ │ ├── SearchBar.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── MDEditor │ │ │ │ ├── MdEditor.stories.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── WrappedEditor.tsx │ │ │ ├── SideTag │ │ │ │ ├── sideTag.stories.tsx │ │ │ │ ├── delete.svg │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── Text │ │ │ │ ├── Text.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── TitleInput │ │ │ │ ├── Inputs.stories.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styled.ts │ │ │ ├── Indicator │ │ │ │ ├── Indicator.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── MDViewer │ │ │ │ ├── MDViewer.stories.tsx │ │ │ │ ├── WrappedViewer.tsx │ │ │ │ └── index.tsx │ │ │ ├── Input │ │ │ │ ├── SearchBar.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── LiveAudio │ │ │ │ └── index.tsx │ │ │ ├── Switch │ │ │ │ ├── Switch.stories.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styled.ts │ │ │ ├── Tag │ │ │ │ ├── Tag.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ ├── molecules │ │ │ ├── LoginModal │ │ │ │ ├── github.png │ │ │ │ ├── LoginModal.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── Vote │ │ │ │ ├── upArrow.svg │ │ │ │ ├── downArrow.svg │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── ProfileDropdown │ │ │ │ ├── user.png │ │ │ │ ├── logout.png │ │ │ │ ├── ProfileDropdown.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── IconWithNumber │ │ │ │ ├── styled.ts │ │ │ │ ├── IconWithNumber.stories.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── answers.svg │ │ │ │ └── views.svg │ │ │ ├── ProfileAnswer │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── ViewsAndComment │ │ │ │ ├── styled.ts │ │ │ │ ├── ViewsAndComment.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── TagList │ │ │ │ ├── styled.ts │ │ │ │ ├── TagList.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── TagInput │ │ │ │ ├── styled.ts │ │ │ │ ├── TagInput.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── QuestionTitle │ │ │ │ ├── QuestionTitle.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── TagSearch │ │ │ │ ├── plus2.svg │ │ │ │ ├── TagSearch.stories.tsx │ │ │ │ ├── plus.svg │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── AudioStreamProfile │ │ │ │ └── index.tsx │ │ │ ├── ProfileSummary │ │ │ │ ├── styled.ts │ │ │ │ ├── ProfileSummary.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── ProfileHeader │ │ │ │ ├── styled.ts │ │ │ │ ├── index.tsx │ │ │ │ └── ProfileHeader.stories.tsx │ │ │ ├── index.ts │ │ │ ├── ExitCheckModalWapper │ │ │ │ └── styled.ts │ │ │ └── Modal │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ ├── organisms │ │ │ ├── AnswerDetail │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── ProfileAnswerSummary │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── ProfileQuestionSummary │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── AnswerRegister │ │ │ │ └── styled.ts │ │ │ ├── LiveChat │ │ │ │ └── LiveChat.stories.tsx │ │ │ ├── AudioStreamProfileList │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── Header │ │ │ │ ├── Header.stories.tsx │ │ │ │ └── styled.ts │ │ │ ├── LiveAudioStream │ │ │ │ ├── config.ts │ │ │ │ ├── Audio.tsx │ │ │ │ └── styled.ts │ │ │ ├── SideBar │ │ │ │ ├── filter.svg │ │ │ │ ├── SideBar.stories.tsx │ │ │ │ ├── tags.svg │ │ │ │ ├── live.svg │ │ │ │ ├── hashtag.svg │ │ │ │ └── styled.ts │ │ │ ├── index.ts │ │ │ ├── LeaderBoard │ │ │ │ ├── trophy.svg │ │ │ │ ├── question.svg │ │ │ │ └── thumbsup.svg │ │ │ ├── Question │ │ │ │ ├── Question.stories.tsx │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── RealTimeEditor │ │ │ │ ├── index.tsx │ │ │ │ └── editor.tsx │ │ │ ├── QuestionDetail │ │ │ │ └── styled.ts │ │ │ └── DetailBody │ │ │ │ └── styled.ts │ │ └── templates │ │ │ ├── QuestionList │ │ │ ├── styled.ts │ │ │ ├── index.tsx │ │ │ └── QuestionList.stories.tsx │ │ │ ├── index.ts │ │ │ ├── ResisterQuestion │ │ │ ├── ResisterQuestion.stories.tsx │ │ │ └── styled.ts │ │ │ ├── RealTimeModal │ │ │ └── styled.ts │ │ │ └── SEOHeader │ │ │ └── index.tsx │ ├── lib │ │ ├── message.ts │ │ └── apolloClient.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── WritePage │ │ │ └── index.tsx │ │ ├── question │ │ │ └── edit │ │ │ │ └── [id].tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ └── auth │ │ │ │ └── [...nextauth].ts │ │ └── 404.tsx │ └── types │ │ └── index.ts ├── public │ └── favicon.ico ├── .eslintrc.json ├── .storybook │ ├── main.js │ ├── preview-head.html │ ├── preview-body.html │ └── preview.js ├── jest.config.js ├── next-env.d.ts ├── .babelrc ├── .gitignore ├── Dockerfile ├── next.config.js ├── tsconfig.json ├── README.md └── package.json ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ └── ----feature.md └── workflows │ ├── Test.yml │ └── Deploy.yml ├── backend ├── src │ ├── types │ │ └── request │ │ │ └── index.d.ts │ ├── entities │ │ ├── AnswerThumb.ts │ │ ├── QuestionThumb.ts │ │ ├── abstract │ │ │ └── Thumb.ts │ │ ├── Tag.ts │ │ ├── UserHasTag.ts │ │ ├── User.ts │ │ ├── PostAnswer.ts │ │ └── PostQuestion.ts │ ├── errors │ │ ├── CommonError.ts │ │ ├── NoSuchUserError.ts │ │ ├── AuthorizationError.ts │ │ ├── AuthenticationError.ts │ │ └── NoSuchQuestionError.ts │ ├── resolvers │ │ ├── index.ts │ │ ├── TagResolver.ts │ │ └── UserResolver.ts │ ├── dto │ │ ├── AnswerInput.ts │ │ ├── UserDto.ts │ │ ├── QuestionInput.ts │ │ └── SearchQuestionInput.ts │ ├── services │ │ ├── User │ │ │ ├── UserService.ts │ │ │ └── UserServiceImpl.ts │ │ ├── Thumb │ │ │ └── ThumbService.ts │ │ ├── Tag │ │ │ ├── TagService.ts │ │ │ └── TagServiceImpl.ts │ │ ├── Answer │ │ │ └── AnswerService.ts │ │ └── Question │ │ │ └── QuestionService.ts │ ├── repositories │ │ ├── Tag │ │ │ ├── TagRepository.ts │ │ │ └── TagRepositoryImpl.ts │ │ ├── UserHasTag │ │ │ ├── UserHasTagRepository.ts │ │ │ └── UserHasTagRepositoryImpl.ts │ │ ├── User │ │ │ ├── UserRepository.ts │ │ │ └── UserRepositoryImpl.ts │ │ ├── AnswerThumb │ │ │ ├── AnswerThumbRepository.ts │ │ │ └── AnswerThumbRepositoryImpl.ts │ │ ├── QuestionThumb │ │ │ ├── QuestionThumbRepository.ts │ │ │ └── QuestionThumbRepositoryImpl.ts │ │ ├── Answer │ │ │ ├── AnswerRepository.ts │ │ │ └── AnswerRepositoryImpl.ts │ │ └── Question │ │ │ └── QuestionRepository.ts │ ├── middlewares │ │ └── Auth.ts │ ├── migrations │ │ ├── 1637838136299-QuestionAdoptedColumn.ts │ │ ├── 1637756599448-AnswerThumbTable.ts │ │ └── 1637689386694-QuestionThumbTable.ts │ ├── InjectionConfig.ts │ └── App.ts ├── test │ ├── integration │ │ ├── mockdata │ │ │ ├── Mock.ts │ │ │ ├── TagMock.ts │ │ │ ├── AnswerMock.ts │ │ │ ├── UserMock.ts │ │ │ └── QuestionMock.ts │ │ ├── connection.ts │ │ ├── User.test.ts │ │ └── Answer.test.ts │ └── unit │ │ ├── repository │ │ ├── AnswerRepo.test.ts │ │ ├── TagRepo.test.ts │ │ └── UserRepo.test.ts │ │ └── service │ │ ├── InjectRepo.ts │ │ ├── AnswerService.test.ts │ │ └── QuestionService.test.ts ├── .gitignore ├── jest.config.js ├── Dockerfile ├── tsconfig.json └── package.json ├── .dockerignore └── docker-compose.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /frontend/test/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/styles/reset.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 이슈 2 | 3 | ## 구현한 기능 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["next/core-web-vitals", "next/babel"] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Chart/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Chart = styled.div``; 4 | -------------------------------------------------------------------------------- /backend/src/types/request/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | userId?: number; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /backend/test/integration/mockdata/Mock.ts: -------------------------------------------------------------------------------- 1 | export default interface Mock { 2 | getOne(): T; 3 | getMany(count: number): T[]; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/atoms/Logo/logo.png -------------------------------------------------------------------------------- /frontend/src/components/atoms/Button/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/atoms/Button/user.png -------------------------------------------------------------------------------- /frontend/src/components/atoms/Button/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/atoms/Button/logout.png -------------------------------------------------------------------------------- /frontend/src/components/atoms/Logo/shortLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/atoms/Logo/shortLogo.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | 3 | frontend/node_modules 4 | frontend/.storybook 5 | frontend/test 6 | frontend/.next 7 | 8 | backend/node_modules 9 | backend/test -------------------------------------------------------------------------------- /frontend/src/components/atoms/IconWithNumber/views.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/atoms/IconWithNumber/views.png -------------------------------------------------------------------------------- /frontend/src/components/molecules/LoginModal/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/molecules/LoginModal/github.png -------------------------------------------------------------------------------- /frontend/src/components/molecules/Vote/upArrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.tsx"], 3 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"], 4 | }; 5 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/IconWithNumber/comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/atoms/IconWithNumber/comments.png -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileDropdown/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/molecules/ProfileDropdown/user.png -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileDropdown/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/WEB01-NPE/HEAD/frontend/src/components/molecules/ProfileDropdown/logout.png -------------------------------------------------------------------------------- /frontend/src/components/molecules/Vote/downArrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/lib/message.ts: -------------------------------------------------------------------------------- 1 | export const DELETE_MESSAGE = "삭제하시겠습니까?"; 2 | 3 | export const CONFIRM_MESSAGE = "채택되었습니다."; 4 | 5 | export const CANNOT_CONFIRM_MESSAGE = "채택할 수 없습니다."; 6 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/IconWithNumber/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const IconWithNumber = styled.span` 4 | img { 5 | margin-right: 10px; 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/AnswerDetail/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Container = styled.section` 4 | border: 1px solid #e1e4e8; 5 | margin-bottom: 50px; 6 | `; 7 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/IconWithNumber/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const IconWithNumber = styled.span` 4 | img { 5 | margin-right: 15px; 6 | } 7 | font-size: 20px; 8 | `; 9 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # 노드 모듈 파일 2 | /node_modules 3 | 4 | # DB 설정 파일 5 | ormconfig.json 6 | 7 | # 빌드된 파일 8 | dist/ 9 | 10 | # 환경변수 파일 11 | .env 12 | 13 | # legacy 14 | src/entities/legacy 15 | 16 | # pm2 실행 설정 파일 17 | pm2.json -------------------------------------------------------------------------------- /backend/src/entities/AnswerThumb.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from "typeorm"; 2 | import Thumb from "./abstract/Thumb"; 3 | 4 | @Entity() 5 | export default class AnswerThumb extends Thumb { 6 | @Column("int") 7 | postAnswerId: number; 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/entities/QuestionThumb.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from "typeorm"; 2 | import Thumb from "./abstract/Thumb"; 3 | 4 | @Entity() 5 | export default class QuestionThumb extends Thumb { 6 | @Column("int") 7 | postQuestionId: number; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/ContentText/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | interface textStyleProps { 4 | color: string; 5 | } 6 | 7 | export const Text = styled.p` 8 | color: ${(props) => props.color}; 9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderText/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | interface textStyleProps { 4 | color: string; 5 | } 6 | 7 | export const Text = styled.h1` 8 | color: ${(props) => props.color}; 9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TitleText/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | interface textStyleProps { 4 | color: string; 5 | } 6 | 7 | export const StyledText = styled.h2` 8 | color: ${(props) => props.color}; 9 | `; 10 | -------------------------------------------------------------------------------- /backend/src/errors/CommonError.ts: -------------------------------------------------------------------------------- 1 | export default class CommonError extends Error { 2 | constructor(message?: string) { 3 | super(`CommonError / ${message ?? ""}`); 4 | this.name = "CommonError"; 5 | this.stack = `${this.message}\n${new Error().stack}`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/templates/QuestionList/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const QuestionList = styled.ul` 4 | display: flex; 5 | flex-direction: column; 6 | width: 640px; 7 | `; 8 | 9 | export const QuestionItem = styled.li``; 10 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileAnswer/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ProfileAnswer = styled.div` 4 | width: 600px; 5 | border-top: 1px solid black; 6 | margin: 0px; 7 | margin-top: 16px; 8 | padding: 0px 10px; 9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/ProfileAnswerSummary/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ProfileAnswerSummary = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | 7 | ul { 8 | padding-inline-start: 0px; 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ViewsAndComment/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ViewsAndComment = styled.div` 4 | display: flex; 5 | li + li { 6 | margin-left: 10px; 7 | } 8 | `; 9 | 10 | export const IconContainer = styled.li``; 11 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/ProfileQuestionSummary/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ProfileQuestionSummary = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | 7 | ul { 8 | padding-inline-start: 0px; 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /frontend/src/components/templates/index.ts: -------------------------------------------------------------------------------- 1 | export { default as QuestionList } from "./QuestionList"; 2 | export { default as ResisterQuestion } from "./ResisterQuestion"; 3 | export { default as RealTimeModal } from "./RealTimeModal"; 4 | export { default as SEOHeader } from "./SEOHeader"; 5 | -------------------------------------------------------------------------------- /backend/src/errors/NoSuchUserError.ts: -------------------------------------------------------------------------------- 1 | export default class NoSuchUserError extends Error { 2 | constructor(message?: string) { 3 | super(`NoSuchUserError / ${message ?? ""}`); 4 | this.name = "NoSuchUserError"; 5 | this.stack = `${this.message}\n${new Error().stack}`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TitleText/TitleText.stories.tsx: -------------------------------------------------------------------------------- 1 | import TitleText from "."; 2 | 3 | export default { 4 | Component: TitleText, 5 | title: "Atoms/TitleText", 6 | }; 7 | 8 | export const Default = () => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/AnswerRegister/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const AnswerRegister = styled.form` 4 | margin-bottom: 50px; 5 | `; 6 | 7 | export const AnswerBtnContainer = styled.div` 8 | margin-top: 50px; 9 | width: 150px; 10 | `; 11 | -------------------------------------------------------------------------------- /backend/src/errors/AuthorizationError.ts: -------------------------------------------------------------------------------- 1 | export default class AuthorizationError extends Error { 2 | constructor(message?: string) { 3 | super(`AuthorizationError / ${message ?? ""}`); 4 | this.name = "AuthorizationError"; 5 | this.stack = `${this.message}\n${new Error().stack}`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import AnswerResolver from "./AnswerResolver"; 2 | import QuestionResolver from "./QuestionResolver"; 3 | import TagResolver from "./TagResolver"; 4 | import UserResolver from "./UserResolver"; 5 | 6 | export { AnswerResolver, QuestionResolver, TagResolver, UserResolver }; 7 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderText/HeaderText.stories.tsx: -------------------------------------------------------------------------------- 1 | import HeaderText from "."; 2 | 3 | export default { 4 | Component: HeaderText, 5 | title: "Atoms/HeaderText", 6 | }; 7 | 8 | export const Default = () => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagList/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const TagList = styled.ul` 4 | display: flex; 5 | flex-wrap: wrap; 6 | margin: 0; 7 | padding: 0; 8 | `; 9 | 10 | export const Tag = styled.li` 11 | margin-right: 10px; 12 | `; 13 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LiveChat/LiveChat.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LiveChat from "./index"; 3 | 4 | export default { 5 | title: "Organisms/LiveChat", 6 | component: LiveChat, 7 | }; 8 | 9 | export const Default = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /backend/src/errors/AuthenticationError.ts: -------------------------------------------------------------------------------- 1 | export default class AuthenticationError extends Error { 2 | constructor(message?: string) { 3 | super(`AuthenticationError / ${message ?? ""}`); 4 | this.name = "AuthenticationError"; 5 | this.stack = `${this.message}\n${new Error().stack}`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/errors/NoSuchQuestionError.ts: -------------------------------------------------------------------------------- 1 | export default class NoSuchQuestionError extends Error { 2 | constructor(message?: string) { 3 | super(`NoSuchQuestionError / ${message ?? ""}`); 4 | this.name = "NoSuchQuestionError"; 5 | this.stack = `${this.message}\n${new Error().stack}`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/ContentText/ContentText.stories.tsx: -------------------------------------------------------------------------------- 1 | import ContentText from "."; 2 | 3 | export default { 4 | Component: ContentText, 5 | title: "Atoms/ContentText", 6 | }; 7 | 8 | export const Default = () => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SearchInput/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const SearchInput = styled.input` 4 | width: 100%; 5 | border: none; 6 | border-bottom: 2px solid #f48024; 7 | :focus { 8 | outline: none; 9 | } 10 | margin-right: 10px; 11 | `; 12 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/LoginModal/LoginModal.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LoginModal from "./index"; 3 | 4 | export default { 5 | title: "Molecules/LoginModal", 6 | component: LoginModal, 7 | }; 8 | 9 | export const Default = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/components/templates/ResisterQuestion/ResisterQuestion.stories.tsx: -------------------------------------------------------------------------------- 1 | import ResisterQuestion from "."; 2 | 3 | export default { 4 | Component: ResisterQuestion, 5 | title: "Templates/ResisterQuestion", 6 | }; 7 | 8 | export const Default = () => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagInput/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Container = styled.form``; 4 | 5 | export const TagContainer = styled.div` 6 | display: flex; 7 | flex-wrap: wrap; 8 | & > a { 9 | margin-right: 5px; 10 | } 11 | margin-top: 15px; 12 | `; 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/----feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 새로운 Feature 3 | about: 새로운 Feature를 위한 issue template 4 | title: "[FE/BE] [Fix/Feature/Design/Refactor/Misc/Update] {Feature Name}" 5 | labels: '' 6 | assignees: getState, sa02045, hwangwoojin, david02324 7 | 8 | --- 9 | 10 | - 내용은 자유 (개발할 내용, 참고할 점 등) 11 | - 라벨 꼭 달아주세요! 없으면 만들어서! 12 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileDropdown/ProfileDropdown.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ProfileDropDown from "./index"; 3 | 4 | export default { 5 | title: "Molecules/ProfileDropDown", 6 | component: ProfileDropDown, 7 | }; 8 | 9 | export const Default = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | setupFilesAfterEnv: ["jest-sorted"], 7 | globals: { 8 | "ts-jest": { 9 | isolatedModules: true, 10 | }, 11 | }, 12 | moduleNameMapper: { 13 | "@src/(.*)$": "/src/$1", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/MDEditor/MdEditor.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import MDEditor from "."; 3 | 4 | export default { 5 | Component: MDEditor, 6 | title: "Atoms/MDEditor", 7 | }; 8 | 9 | export const Default = () => { 10 | const ref = useRef(); 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SearchInput/SearchBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SearchInput from "./index"; 3 | 4 | export default { 5 | title: "Atoms/SearchInput", 6 | component: SearchInput, 7 | }; 8 | 9 | export const Default = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /backend/src/dto/AnswerInput.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { Length } from "class-validator"; 3 | import { Field, InputType } from "type-graphql"; 4 | 5 | @InputType({ description: "답변글 작성/수정시 사용되는 인자들" }) 6 | export default class AnswerInput { 7 | @Field({ description: "내용(최소 10자 이상)" }) 8 | @Length(10) 9 | desc: string; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SideTag/sideTag.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import SideTag from "./index"; 3 | 4 | export default { 5 | title: "Atoms/SideTag", 6 | component: SideTag, 7 | }; 8 | 9 | export const Default = () => { 10 | return {}} />; 11 | }; 12 | -------------------------------------------------------------------------------- /backend/src/services/User/UserService.ts: -------------------------------------------------------------------------------- 1 | import UserDto from "@src/dto/UserDto"; 2 | import User from "../../entities/User"; 3 | 4 | export default interface UserService { 5 | findById(id: number): Promise; 6 | findByUsername(username: string): Promise; 7 | register(userDto: UserDto): Promise; 8 | getRank(): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Text/Text.stories.tsx: -------------------------------------------------------------------------------- 1 | import Text from "."; 2 | 3 | export default { 4 | Component: Text, 5 | title: "Atoms/Text", 6 | }; 7 | export const Default = () => { 8 | return ; 9 | }; 10 | 11 | export const Header = () => { 12 | return ; 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Logo/Logo.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Logo from "./index"; 3 | 4 | export default { 5 | title: "Atoms/Logo", 6 | component: Logo, 7 | }; 8 | 9 | export const Default = () => { 10 | return ; 11 | }; 12 | 13 | export const Short = () => { 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ViewsAndComment/ViewsAndComment.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ViewsAndComment from "./index"; 3 | 4 | export default { 5 | title: "Organisms/ViewsAndComment", 6 | component: ViewsAndComment, 7 | }; 8 | 9 | export const example = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/AudioStreamProfileList/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ProfileList = styled.ul` 4 | display: flex; 5 | flex-direction: column; 6 | div { 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | } 11 | li + li { 12 | margin-top: 20px; 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /backend/src/services/Thumb/ThumbService.ts: -------------------------------------------------------------------------------- 1 | export default interface ThumbService { 2 | questionThumbUp(questionId: number, userId: number): Promise; 3 | questionThumbDown(questionId: number, userId: number): Promise; 4 | answerThumbUp(answerId: number, userId: number): Promise; 5 | answerThumbDown(answerId: number, userId: number): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TitleInput/Inputs.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import TitleInput from "."; 3 | 4 | export default { 5 | Component: TitleInput, 6 | title: "Atoms/TitleInput", 7 | }; 8 | 9 | export const Default = () => { 10 | const [text, setText] = useState(""); 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/Header/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "./index"; 3 | 4 | export default { 5 | title: "Organisms/Header", 6 | component: Header, 7 | }; 8 | 9 | export const Default = () => { 10 | return
; 11 | }; 12 | 13 | export const Profile = () => { 14 | return
; 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SearchInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | placeholder: string; 7 | } 8 | 9 | const SearchInput: FunctionComponent = ({ placeholder }) => { 10 | return ; 11 | }; 12 | 13 | export default SearchInput; 14 | -------------------------------------------------------------------------------- /backend/src/repositories/Tag/TagRepository.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import Tag from "../../entities/Tag"; 3 | 4 | export default interface TagRepository { 5 | findAll(): Promise; 6 | findById(id: number): Promise; 7 | findByName(name: string): Promise; 8 | findByIds(ids: number[]): Promise; 9 | findByQuestionId(questionId: number): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/repositories/UserHasTag/UserHasTagRepository.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import UserHasTag from "../../entities/UserHasTag"; 3 | 4 | export default interface UserHasTagRepository { 5 | findByUserId(userId: number): Promise; 6 | addNewRelations(userId: number, tagIds: number[]): Promise; 7 | increseAll(userId: number, tagIds: number[]): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Indicator/Indicator.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Indicator from "./index"; 3 | 4 | export default { 5 | title: "Atoms/Indicator", 6 | component: Indicator, 7 | }; 8 | 9 | export const online = () => { 10 | return ; 11 | }; 12 | 13 | export const offline = () => { 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /backend/src/entities/abstract/Thumb.ts: -------------------------------------------------------------------------------- 1 | import { Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | export enum ThumbValue { 4 | UP = 1, 5 | DOWN = -1, 6 | } 7 | 8 | export default abstract class Thumb { 9 | @PrimaryGeneratedColumn() 10 | id: number; 11 | 12 | @Column("int") 13 | userId: number; 14 | 15 | @Column({ type: "enum", enum: ThumbValue }) 16 | value: ThumbValue; 17 | } 18 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM node:16-alpine AS builder 3 | WORKDIR /app/backend 4 | COPY . . 5 | RUN yarn install 6 | RUN yarn build 7 | 8 | # running stage 9 | FROM node:16-alpine 10 | WORKDIR /app/backend 11 | 12 | # COPY built files 13 | COPY --from=builder /app/backend/dist . 14 | 15 | # install pm2 16 | RUN yarn global add pm2 17 | # install dependencies 18 | RUN yarn install --no-lockfile --production -------------------------------------------------------------------------------- /frontend/src/components/atoms/MDViewer/MDViewer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MDViewer from "."; 3 | 4 | export default { 5 | Component: MDViewer, 6 | title: "Atoms/MDViewer", 7 | }; 8 | 9 | export const Default = () => { 10 | return ( 11 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Vote/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Vote = styled.div` 4 | padding: 0; 5 | display: flex; 6 | flex-direction: column; 7 | text-align: center; 8 | `; 9 | 10 | export const UpArrowDiv = styled.div` 11 | cursor: pointer; 12 | `; 13 | export const DownArrowDiv = styled.div` 14 | cursor: pointer; 15 | `; 16 | export const ThubmUpDiv = styled.div``; 17 | -------------------------------------------------------------------------------- /frontend/.storybook/preview-body.html: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LiveAudioStream/config.ts: -------------------------------------------------------------------------------- 1 | export const MEDIA_CONFIG = { 2 | audio: true, 3 | video: false, 4 | }; 5 | 6 | export const ICE_SERVERS = { 7 | iceServers: [ 8 | { 9 | urls: ["stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302"], 10 | }, 11 | ], 12 | }; 13 | 14 | export const OFFER_RECEIVE = { 15 | offerToReceiveAudio: true, 16 | offerToReceiveVideo: false, 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import * as nextImage from 'next/image'; 2 | 3 | Object.defineProperty(nextImage, 'default', { 4 | configurable: true, 5 | value: props => < img { 6 | ...props 7 | } 8 | /> 9 | }); 10 | 11 | export const parameters = { 12 | actions: { 13 | argTypesRegex: "^on[A-Z].*" 14 | }, 15 | controls: { 16 | matchers: { 17 | color: /(background|color)$/i, 18 | date: /Date$/, 19 | }, 20 | }, 21 | } -------------------------------------------------------------------------------- /frontend/src/components/atoms/Indicator/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | interface IndicatorProps { 4 | bgColor: string; 5 | width: string; 6 | height: string; 7 | } 8 | 9 | const Indicator = styled.div` 10 | width: ${(props) => props.width}; 11 | height: ${(props) => props.height}; 12 | background-color: ${(props) => props.bgColor}; 13 | border-radius: 999px; 14 | `; 15 | 16 | export { Indicator }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/IconWithNumber/IconWithNumber.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import IconWithNumber from "./index"; 3 | 4 | export default { 5 | title: "Atoms/IconWithNumber", 6 | component: IconWithNumber, 7 | }; 8 | 9 | export const Comments = () => { 10 | return ; 11 | }; 12 | 13 | export const Views = () => { 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/SideBar/filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/repositories/User/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import UserDto from "@src/dto/UserDto"; 2 | import "reflect-metadata"; 3 | import User from "../../entities/User"; 4 | 5 | export default interface UserRepository { 6 | findById(id: number): Promise; 7 | findByUsername(username: string): Promise; 8 | addNew(user: UserDto): Promise; 9 | saveOrUpdate(user: User): Promise; 10 | findAndOrderByScoreDesc(take: number): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/dto/UserDto.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType, Int } from "type-graphql"; 2 | 3 | @InputType({ description: "User Dto" }) 4 | export default class UserDto { 5 | @Field(() => Int, { description: "Github ID" }) 6 | id: number; 7 | 8 | @Field({ description: "Github 유저 이름" }) 9 | username: string; 10 | 11 | @Field({ description: "Github 프로필 URL" }) 12 | profileUrl: string; 13 | 14 | @Field({ description: "Github URL" }) 15 | socialUrl: string; 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/Test.yml: -------------------------------------------------------------------------------- 1 | name: 브랜치 Merge 전 Jest 테스트 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | Jest_Test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: install dependencies 18 | run: cd backend && yarn install 19 | 20 | - name: run test 21 | run: cd backend && yarn test:unit 22 | -------------------------------------------------------------------------------- /backend/src/services/Tag/TagService.ts: -------------------------------------------------------------------------------- 1 | import UserHasTag from "../../entities/UserHasTag"; 2 | import Tag from "../../entities/Tag"; 3 | 4 | export default interface TagService { 5 | findAll(): Promise; 6 | findById(id: number): Promise; 7 | findByIds(ids: number[]): Promise; 8 | findByName(name: string): Promise; 9 | findAllIdsByQuestionId(questionId: number): Promise; 10 | findByUserId(userId: number): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/repositories/AnswerThumb/AnswerThumbRepository.ts: -------------------------------------------------------------------------------- 1 | import { ThumbValue } from "@src/entities/abstract/Thumb"; 2 | import AnswerThumb from "@src/entities/AnswerThumb"; 3 | 4 | export default interface AnswerThumbRepository { 5 | deleteByAnswerId(answerId: number): Promise; 6 | addNew( 7 | value: ThumbValue, 8 | answerId: number, 9 | userId: number 10 | ): Promise; 11 | exists(answerId: number, userId: number): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/repositories/QuestionThumb/QuestionThumbRepository.ts: -------------------------------------------------------------------------------- 1 | import { ThumbValue } from "@src/entities/abstract/Thumb"; 2 | import QuestionThumb from "@src/entities/QuestionThumb"; 3 | 4 | export default interface QuestionThumbRepository { 5 | deleteByQuestionId(questionId: number): Promise; 6 | addNew( 7 | value: ThumbValue, 8 | questionId: number, 9 | userId: number 10 | ): Promise; 11 | exists(questionId: number, userId: number): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Input/SearchBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Input from "./index"; 3 | 4 | export default { 5 | title: "Atoms/Input", 6 | component: Input, 7 | }; 8 | 9 | export const Small = () => { 10 | return ; 11 | }; 12 | 13 | export const Medium = () => { 14 | return ; 15 | }; 16 | 17 | export const Long = () => { 18 | return ; 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Input/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const input = styled.input` 4 | width: 100%; 5 | border: none; 6 | border-bottom: 2px solid #f48024; 7 | :focus { 8 | outline: none; 9 | } 10 | margin-right: 10px; 11 | `; 12 | 13 | export const smallInput = styled(input)` 14 | width: 100px; 15 | `; 16 | export const mediumInput = styled(input)` 17 | width: 200px; 18 | `; 19 | export const largeInput = styled(input)` 20 | width: 643px; 21 | `; 22 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LiveAudioStream/Audio.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, FunctionComponent } from "react"; 2 | 3 | interface Props { 4 | stream: MediaStream; 5 | } 6 | const Audio: FunctionComponent = ({ stream }) => { 7 | const ref = useRef(null); 8 | useEffect(() => { 9 | if (ref.current) { 10 | ref.current.srcObject = stream; 11 | } 12 | }); 13 | 14 | return ; 15 | }; 16 | 17 | export default Audio; 18 | -------------------------------------------------------------------------------- /backend/test/integration/mockdata/TagMock.ts: -------------------------------------------------------------------------------- 1 | import Tag from "@src/entities/Tag"; 2 | import Mock from "./mock"; 3 | import faker from "faker"; 4 | 5 | export default class TagMock implements Mock { 6 | getOne() { 7 | const tag = new Tag(); 8 | tag.name = faker.datatype.string(10); 9 | 10 | return tag; 11 | } 12 | 13 | getMany(count: number) { 14 | const tags: Tag[] = []; 15 | for (let i = 0; i < count; i++) tags.push(this.getOne()); 16 | 17 | return tags; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/LiveAudio/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, FunctionComponent } from "react"; 2 | 3 | interface Props { 4 | stream: MediaStream; 5 | } 6 | 7 | const LiveAudio: FunctionComponent = ({ stream }) => { 8 | const ref = useRef(null); 9 | useEffect(() => { 10 | if (ref.current) { 11 | ref.current.srcObject = stream; 12 | } 13 | }); 14 | 15 | return ; 16 | }; 17 | 18 | export default LiveAudio; 19 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "module-resolver", 6 | { 7 | "root": ["./src"], 8 | "alias": { 9 | "@src": "./src", 10 | "@components": "./src/components", 11 | "@lib": "./src/lib/" 12 | } 13 | } 14 | ], 15 | [ 16 | "styled-components", 17 | { 18 | "ssr": true, 19 | "displayName": true, 20 | "preprocess": false 21 | } 22 | ] 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/middlewares/Auth.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | import { Request } from "express"; 3 | import { verify } from "jsonwebtoken"; 4 | 5 | const Auth: MiddlewareFn = ({ context, info }, next) => { 6 | if (context.headers.authorization) { 7 | const token = context.headers.authorization.split(" ")[1]; 8 | const data = verify(token, process.env.JWT_TOKEN) as { userId: number }; 9 | context.userId = data.userId; 10 | } 11 | return next(); 12 | }; 13 | 14 | export default Auth; 15 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/IconWithNumber/IconWithNumber.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import IconWithNumber from "./index"; 3 | import comments from "./comments.png"; 4 | import views from "./views.png"; 5 | export default { 6 | title: "Atoms/IconWithNumber", 7 | component: IconWithNumber, 8 | }; 9 | 10 | export const Comments = () => { 11 | return ; 12 | }; 13 | 14 | export const Views = () => { 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LiveAudioStream/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Container = styled.div` 4 | display: flex; 5 | width: 200px; 6 | height: 100%; 7 | `; 8 | 9 | export const ProfileList = styled.div` 10 | display: flex; 11 | flex-direction: column; 12 | margin-left: 25px; 13 | margin-top: 25px; 14 | div { 15 | display: flex; 16 | justify-content: space-between; 17 | align-items: center; 18 | } 19 | li + li { 20 | margin-top: 20px; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagList/TagList.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TagList from "./index"; 3 | 4 | export default { 5 | title: "Molecules/TagList", 6 | component: TagList, 7 | }; 8 | 9 | export const exmaple = () => { 10 | const tags = [ 11 | { 12 | __typename: "Tag", 13 | name: "React", 14 | }, 15 | { 16 | __typename: "Tag", 17 | name: "Javascript", 18 | }, 19 | ]; 20 | return ( 21 | <> 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | client: 4 | build: 5 | dockerfile: Dockerfile 6 | context: ./frontend 7 | command: sh -c "npm run start" 8 | ports: 9 | - "3000:3000" 10 | server: 11 | build: 12 | dockerfile: Dockerfile 13 | context: ./backend 14 | ports: 15 | - "1234:1234" 16 | - "4000:4000" 17 | environment: 18 | PM2_PUBLIC_KEY: $PM2_PUBLIC_KEY 19 | PM2_SECRET_KEY: $PM2_SECRET_KEY 20 | command: sh -c "pm2-runtime pm2.json & npx y-websocket-server" 21 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/QuestionTitle/QuestionTitle.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import QuestionTitle from "./index"; 3 | 4 | export default { 5 | title: "Molecules/QuestionTitle", 6 | component: QuestionTitle, 7 | }; 8 | 9 | export const Online = () => { 10 | return ( 11 | <> 12 | 13 | 14 | ); 15 | }; 16 | 17 | export const Offline = () => { 18 | return ( 19 | <> 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/MDEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import dynamic from "next/dynamic"; 3 | interface Props { 4 | type: "Question" | "Answer"; 5 | initialValue?: string; 6 | } 7 | const WrappedEditor = dynamic(() => import("./WrappedEditor"), { ssr: false }); 8 | 9 | const MDEditor = React.forwardRef((props, ref) => { 10 | return ( 11 | 16 | ); 17 | }); 18 | export default MDEditor; 19 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileDropdown/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Dropdown = styled.div` 4 | width: 200px; 5 | height: 129px; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | border: 1px solid #c4c4c4; 11 | border-radius: 10px; 12 | background-color: #fff; 13 | 14 | button:hover { 15 | box-shadow: none; 16 | } 17 | `; 18 | 19 | export const Line = styled.div` 20 | border-bottom: 1px solid #c4c4c4; 21 | width: 150px; 22 | `; 23 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagSearch/plus2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Switch/Switch.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Switch from "./index"; 3 | 4 | export default { 5 | title: "Atoms/Switch", 6 | component: Switch, 7 | }; 8 | 9 | export const Default = () => { 10 | const [check, setCheck] = useState(false); 11 | return ; 12 | }; 13 | 14 | export const DarkMode = () => { 15 | const [check, setCheck] = useState(false); 16 | return ; 17 | }; 18 | -------------------------------------------------------------------------------- /backend/test/integration/mockdata/AnswerMock.ts: -------------------------------------------------------------------------------- 1 | import PostAnswer from "@src/entities/PostAnswer"; 2 | import Mock from "./mock"; 3 | import faker from "faker"; 4 | 5 | export default class AnswerMock implements Mock { 6 | getOne() { 7 | const answer = new PostAnswer(); 8 | answer.desc = faker.datatype.string(20); 9 | answer.createdAt = new Date(); 10 | 11 | return answer; 12 | } 13 | 14 | getMany(count: number) { 15 | const answers: PostAnswer[] = []; 16 | for (let i = 0; i < count; i++) answers.push(this.getOne()); 17 | 18 | return answers; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TitleText/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import { StyledText } from "./styled"; 4 | 5 | interface Props { 6 | type: string; 7 | text: string; 8 | } 9 | 10 | interface StyleProps { 11 | color: string; 12 | } 13 | const types: { [key: string]: StyleProps } = { 14 | Default: { 15 | color: "black", 16 | }, 17 | }; 18 | const TitleText: FunctionComponent = ({ type, text }) => { 19 | const styleProps = types[type]; 20 | return {text}; 21 | }; 22 | 23 | export default TitleText; 24 | -------------------------------------------------------------------------------- /backend/src/entities/Tag.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from "type-graphql"; 2 | import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm"; 3 | import PostQuestion from "./PostQuestion"; 4 | 5 | @ObjectType("Tag", { description: "태그 Ojbect 입니다." }) 6 | @Entity() 7 | export default class Tag { 8 | @Field(() => Int, { description: "태그의 ID" }) 9 | @PrimaryGeneratedColumn() 10 | id: number; 11 | 12 | @Field({ description: "태그의 이름" }) 13 | @Column("varchar", { length: 30 }) 14 | name: string; 15 | 16 | @ManyToMany(() => PostQuestion) 17 | questions: PostQuestion[]; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/ContentText/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | type: string; 7 | text: string; 8 | } 9 | interface StyleProps { 10 | color: string; 11 | } 12 | const types: { [key: string]: StyleProps } = { 13 | Default: { 14 | color: "black", 15 | }, 16 | }; 17 | 18 | const contentText: FunctionComponent = ({ type, text }) => { 19 | const styleProps = types[type]; 20 | return {text}; 21 | }; 22 | 23 | export default contentText; 24 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderText/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | type: string; 7 | text: string; 8 | } 9 | interface StyleProps { 10 | color: string; 11 | } 12 | const types: { [key: string]: StyleProps } = { 13 | Default: { 14 | color: "black", 15 | }, 16 | }; 17 | 18 | const headerText: FunctionComponent = ({ type, text }) => { 19 | const styleProps = types[type]; 20 | return {text}; 21 | }; 22 | 23 | export default headerText; 24 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagInput/TagInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import TagInput from "."; 4 | import { TagType } from "@src/types"; 5 | 6 | export default { 7 | title: "Molecules/TagInput", 8 | component: TagInput, 9 | }; 10 | 11 | export const Default = () => { 12 | const tagList: TagType[] = [ 13 | { __typename: "tag", id: "1", name: "react.js" }, 14 | { __typename: "tag", id: "2", name: "javascript" }, 15 | { __typename: "tag", id: "3", name: "typescript" }, 16 | ]; 17 | 18 | return {}} />; 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagSearch/TagSearch.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import TagSearch from "./index"; 4 | import { TagType } from "@src/types"; 5 | 6 | export default { 7 | title: "Molecules/TagSearch", 8 | component: TagSearch, 9 | }; 10 | 11 | export const Default = () => { 12 | const tagList: TagType[] = [ 13 | { __typename: "tag", id: "1", name: "react.js" }, 14 | { __typename: "tag", id: "2", name: "javascript" }, 15 | { __typename: "tag", id: "3", name: "typescript" }, 16 | ]; 17 | 18 | return {}} tagList={tagList} />; 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/SideBar/SideBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import SideBar from "./index"; 3 | 4 | export default { 5 | title: "Organisms/SideBar", 6 | component: SideBar, 7 | }; 8 | 9 | export const Default = () => { 10 | const [selectedTags, setSelectedTags] = useState([]); 11 | const [isLive, setIsLive] = useState(true); 12 | return ( 13 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/AudioStreamProfile/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import Image from "next/image"; 3 | 4 | import { Text } from "@components/atoms"; 5 | 6 | interface Props { 7 | name: string; 8 | profileUrl: string; 9 | } 10 | 11 | const AudioStreamProfile: FunctionComponent = ({ name, profileUrl }) => { 12 | return ( 13 |
  • 14 |
    15 | 16 | 17 |
    18 |
  • 19 | ); 20 | }; 21 | 22 | export default AudioStreamProfile; 23 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/LoginModal/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Div = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | width: 300px; 8 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 9 | border-radius: 10px; 10 | padding: 30px 30px 30px 30px; 11 | background-color: #fff; 12 | 13 | > { 14 | button, 15 | img, 16 | div { 17 | margin: 20px 0px 20px 0px; 18 | } 19 | } 20 | `; 21 | 22 | export const Divider = styled.div` 23 | width: 100%; 24 | height: 1px; 25 | background-color: rgba(0, 0, 0, 0.1); 26 | `; 27 | -------------------------------------------------------------------------------- /frontend/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | .env.production 33 | .env.development 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | -------------------------------------------------------------------------------- /backend/src/migrations/1637838136299-QuestionAdoptedColumn.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class QuestionAdoptedColumn1637838136299 implements MigrationInterface { 4 | name = 'QuestionAdoptedColumn1637838136299' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`ALTER TABLE \`post_question\` ADD \`adopted\` tinyint NOT NULL DEFAULT '0'`); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query(`ALTER TABLE \`post_question\` DROP COLUMN \`adopted\``); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/repositories/Answer/AnswerRepository.ts: -------------------------------------------------------------------------------- 1 | import PostAnswer from "../../entities/PostAnswer"; 2 | import AnswerInput from "../../dto/AnswerInput"; 3 | 4 | export default interface AnswerRepository { 5 | findById(answerId: number): Promise; 6 | findAllByUserId(userId: number): Promise; 7 | findAllByQuestionId(id: number): Promise; 8 | saveOrUpdate(entity: PostAnswer): Promise; 9 | modify(answerId: number, answerInput: AnswerInput): Promise; 10 | deleteById(answerId: number): Promise; 11 | countByQuestionId(questionId: number): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Text/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | interface textStyleProps { 4 | color: string; 5 | fontSize: string; 6 | fontWeight: string; 7 | ellipsis?: boolean; 8 | } 9 | 10 | export const Span = styled.span` 11 | color: ${(props) => props.color}; 12 | font-size: ${(props) => props.fontSize}; 13 | font-weight: ${(props) => props.fontWeight}; 14 | overflow: ${(props) => (props.ellipsis ? "hidden" : "visible")}; 15 | text-overflow: ${(props) => (props.ellipsis ? "ellipsis" : "clip")}; 16 | white-space: ${(props) => (props.ellipsis ? "nowrap" : "normal")}; 17 | `; 18 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/QuestionTitle/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const QuestionTitle = styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | // width: 100%; 8 | // height: 50px; 9 | `; 10 | export const TextContainer = styled.div` 11 | display: flex; 12 | flex-wrap: wrap; 13 | & h2 { 14 | margin-right: 5px !important ; 15 | } 16 | `; 17 | export const QuestionDate = styled.div` 18 | display: flex; 19 | align-items: center; 20 | font-size: 14px; 21 | color: #586069; 22 | `; 23 | 24 | export const IndicatorContainer = styled.div``; 25 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/AnswerDetail/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import { AnswerDetailType } from "@src/types"; 4 | import { DetailBody } from "@components/organisms"; 5 | import * as Styled from "./styled"; 6 | 7 | interface Props { 8 | answer: AnswerDetailType; 9 | isAdoptable?: boolean; 10 | } 11 | 12 | const AnswerDetail: FunctionComponent = ({ answer, isAdoptable }) => { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default AnswerDetail; 21 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/MDViewer/WrappedViewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import "prismjs/themes/prism.css"; 3 | import Prism from "prismjs"; 4 | import "@toast-ui/editor/dist/toastui-editor.css"; 5 | import { Viewer } from "@toast-ui/react-editor"; 6 | import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight"; 7 | 8 | const WrappedViewer: FunctionComponent<{ content: string }> = (props) => { 9 | return ( 10 | 14 | ); 15 | }; 16 | 17 | export default WrappedViewer; 18 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Tag/Tag.stories.tsx: -------------------------------------------------------------------------------- 1 | import Tag from "./"; 2 | import * as Type from "../../../types"; 3 | export default { 4 | Component: Tag, 5 | title: "Atoms/Tag", 6 | }; 7 | 8 | export const Default = () => { 9 | const onClick = () => {}; 10 | const tag: Type.Tag = { 11 | __typename: "Tag", 12 | name: "javascript", 13 | }; 14 | return ; 15 | }; 16 | 17 | export const Gray = () => { 18 | const tag: Type.Tag = { 19 | __typename: "Tag", 20 | name: "javascript", 21 | }; 22 | const onClick = () => {}; 23 | return ; 24 | }; 25 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "target": "es2015", 6 | "module": "CommonJS", 7 | "moduleResolution": "node", 8 | "outDir": "dist", 9 | "esModuleInterop": true, 10 | "noImplicitAny": true, 11 | "removeComments": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "@src/*": ["./src/*"] 15 | }, 16 | "resolveJsonModule": true, 17 | "typeRoots": ["./node_modules/@types", "./src/types"] 18 | }, 19 | "include": ["src", "package.json", "pm2.json", "ormconfig.json"], 20 | "typeAcquisition": { 21 | "include": ["jest"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/services/Answer/AnswerService.ts: -------------------------------------------------------------------------------- 1 | import AnswerInput from "../../dto/AnswerInput"; 2 | import PostAnswer from "../../entities/PostAnswer"; 3 | 4 | export default interface AnswerService { 5 | findAllByUserId(userId: number): Promise; 6 | findAllByQuestionId(questionId: number): Promise; 7 | addNew( 8 | args: AnswerInput, 9 | userId: number, 10 | questionId: number 11 | ): Promise; 12 | findById(answerId: number): Promise; 13 | modify(answerId: number, answerInput: AnswerInput): Promise; 14 | delete(answerId: number): Promise; 15 | adopt(answerId: number): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/dto/QuestionInput.ts: -------------------------------------------------------------------------------- 1 | import { Length } from "class-validator"; 2 | import { Field, InputType, Int } from "type-graphql"; 3 | 4 | @InputType({ description: "질문글 작성/수정시 사용되는 인자들" }) 5 | export default class QuestionInput { 6 | @Field({ description: "제목(5자이상 50자이하)" }) 7 | @Length(5, 50) 8 | title: string; 9 | 10 | @Field({ description: "내용(10자 이상)" }) 11 | @Length(10) 12 | desc: string; 13 | 14 | @Field(() => Boolean, { 15 | description: "실시간 공유 여부", 16 | defaultValue: false, 17 | }) 18 | realtimeShare: boolean; 19 | 20 | @Field(() => [Int], { 21 | description: "할당할 태그 id", 22 | nullable: "itemsAndList", 23 | }) 24 | tagIds: number[]; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | import { Tag } from "@components/atoms"; 5 | import { TagType } from "@src/types"; 6 | interface Props { 7 | tags: TagType[]; 8 | } 9 | 10 | const TagList: FunctionComponent = ({ tags }) => { 11 | return ( 12 | 13 | {tags.map((tag) => { 14 | return ( 15 | 16 | {}} /> 17 | 18 | ); 19 | })} 20 | 21 | ); 22 | }; 23 | 24 | export default TagList; 25 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts as dependencies 2 | WORKDIR /app 3 | COPY package.json package-lock.json ./ 4 | RUN npm ci 5 | 6 | FROM node:lts as builder 7 | WORKDIR /app 8 | COPY . . 9 | COPY --from=dependencies /app/node_modules ./node_modules 10 | RUN npm run build 11 | 12 | FROM node:lts as runner 13 | WORKDIR /app 14 | ENV NODE_ENV production 15 | 16 | COPY --from=builder /app/next.config.js ./ 17 | COPY --from=builder /app/public ./public 18 | COPY --from=builder /app/.next ./.next 19 | COPY --from=builder /app/node_modules ./node_modules 20 | COPY --from=builder /app/package.json ./package.json 21 | COPY --from=builder /app/.env.production ./.env.production 22 | 23 | EXPOSE 3000 24 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | /** @type {import('next').NextConfig} */ 3 | module.exports = { 4 | reactStrictMode: true, 5 | images: { 6 | domains: ["avatars.githubusercontent.com", "i.ibb.co"], 7 | }, 8 | typescript: { 9 | ignoreBuildErrors: true, 10 | }, 11 | eslint: { 12 | ignoreDuringBuilds: true, 13 | }, 14 | webpack(config, options) { 15 | config.resolve = { 16 | alias: { 17 | "@src": path.join(__dirname, "src"), 18 | "@components": path.join(__dirname, "src", "components"), 19 | "@lib": path.join(__dirname, "src", "lib"), 20 | }, 21 | ...config.resolve, 22 | }; 23 | 24 | return config; 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | text: string; 7 | size: string; 8 | } 9 | 10 | const SearchInput = forwardRef( 11 | ({ text, size }, ref) => { 12 | if (size === "small") 13 | return ; 14 | if (size === "medium") 15 | return ; 16 | if (size === "large") 17 | return ; 18 | return ; 19 | } 20 | ); 21 | 22 | export default SearchInput; 23 | -------------------------------------------------------------------------------- /frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { Provider as SessionProvider } from "next-auth/client"; 3 | import { ApolloProvider } from "@apollo/client"; 4 | 5 | import client from "@src/lib/apolloClient"; 6 | import "../styles/globals.css"; 7 | 8 | function MyApp({ Component, pageProps }: AppProps) { 9 | const sessionOptions = { 10 | clientMaxAge: 10000, 11 | keepAlive: 10000, 12 | }; 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default MyApp; 23 | -------------------------------------------------------------------------------- /backend/src/entities/UserHasTag.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from "type-graphql"; 2 | import { Column, Entity, Index, PrimaryColumn } from "typeorm"; 3 | 4 | @ObjectType({ 5 | description: "유저의 태그 사용 관계 Object", 6 | }) 7 | @Entity("user_has_tag") 8 | @Index(["userId", "tagId"], { unique: true }) 9 | export default class UserHasTag { 10 | @Field(() => Int, { 11 | description: "유저의 고유 ID", 12 | }) 13 | @PrimaryColumn("int") 14 | userId: number; 15 | 16 | @Field(() => Int, { 17 | description: "태그 ID", 18 | }) 19 | @PrimaryColumn("int") 20 | tagId: number; 21 | 22 | @Field(() => Int, { 23 | description: "해당 태그 사용 횟수", 24 | }) 25 | @Column("int", { default: 0 }) 26 | count: number; 27 | } 28 | -------------------------------------------------------------------------------- /backend/test/integration/mockdata/UserMock.ts: -------------------------------------------------------------------------------- 1 | import User from "@src/entities/User"; 2 | import Mock from "./mock"; 3 | import faker from "faker"; 4 | 5 | export default class UserMock implements Mock { 6 | getOne() { 7 | const user = new User(); 8 | user.id = faker.datatype.number(); 9 | user.username = faker.internet.userName(); 10 | user.score = faker.datatype.number({ min: 0, max: 2000 }); 11 | user.profileUrl = faker.image.avatar(); 12 | user.socialUrl = faker.internet.url(); 13 | 14 | return user; 15 | } 16 | 17 | getMany(count: number) { 18 | const users: User[] = []; 19 | for (let i = 0; i < count; i++) users.push(this.getOne()); 20 | 21 | return users; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileSummary/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Anchor = styled.a` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | border-radius: 8px; 8 | padding: 10px 8px 10px 8px; 9 | 10 | &:hover { 11 | cursor: pointer; 12 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 13 | } 14 | `; 15 | 16 | export const ImageDiv = styled.div` 17 | display: flex; 18 | border-radius: 50%; 19 | width: 48px; 20 | height: 48px; 21 | overflow: hidden; 22 | `; 23 | 24 | export const TextDiv = styled.div` 25 | display: flex; 26 | flex-direction: column; 27 | text-align: center; 28 | & > span { 29 | width: 95px; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileHeader/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ProfileDiv = styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | border-radius: 8px; 8 | padding: 0px 8px 0px 8px; 9 | width: 136px; 10 | height: 36px; 11 | &:hover { 12 | cursor: pointer; 13 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 14 | } 15 | `; 16 | 17 | export const ImageDiv = styled.div` 18 | display: flex; 19 | border-radius: 50%; 20 | width: 24px; 21 | height: 24px; 22 | overflow: hidden; 23 | `; 24 | 25 | export const TextDiv = styled.div` 26 | display: flex; 27 | align-items: center; 28 | width: 96px; 29 | height: 24px; 30 | `; 31 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SideTag/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TitleInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FunctionComponent, useRef } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | type: string; 7 | setText: (value: string) => void; 8 | } 9 | 10 | const TitleInput: FunctionComponent = ({ type, setText }) => { 11 | const inputRef = useRef(null); 12 | const onInput = (event: ChangeEvent) => { 13 | setText(event.target.value); 14 | }; 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 제목을 입력하세요. 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default TitleInput; 27 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Tag/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | interface TagProps { 4 | fontSize: string; 5 | bgColor: string; 6 | color: string; 7 | hoverTextColor: string; 8 | hoverBgColor: string; 9 | border: string; 10 | } 11 | 12 | export const Tag = styled.a` 13 | font-size: ${(props) => props.fontSize}; 14 | background-color: ${(props) => props.bgColor}; 15 | color: ${(props) => props.color}; 16 | padding: 5px 10px; 17 | border-radius: 10px; 18 | cursor: pointer; 19 | transition: all 0.3s ease-in-out; 20 | border: ${(props) => props.border}; 21 | &:hover { 22 | background-color: ${(props) => props.hoverBgColor}; 23 | color: ${(props) => props.hoverTextColor}; 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Question } from "./Question"; 2 | export { default as Header } from "./Header"; 3 | export { default as SideBar } from "./SideBar"; 4 | export { default as AnswerDetail } from "./AnswerDetail"; 5 | export { default as DetailBody } from "./DetailBody"; 6 | export { default as QuestionDetail } from "./QuestionDetail"; 7 | export { default as AnswerRegister } from "./AnswerRegister"; 8 | export { default as RealTimeEditor } from "./RealTimeEditor"; 9 | export { default as LivaAudioStream } from "./LiveAudioStream"; 10 | export { default as LiveChat } from "./LiveChat"; 11 | export { default as LeaderBoard } from "./LeaderBoard"; 12 | export { default as AudioStreamProfileList } from "./AudioStreamProfileList"; 13 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "paths": { 6 | "@src/*": ["./src/*"], 7 | "@components/*": ["./src/components/*"], 8 | "@lib/*": ["./src/lib/*"] 9 | }, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve", 21 | "incremental": true 22 | }, 23 | "baseUrl": ".", 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TagSearch/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/SideBar/tags.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileAnswer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import { ContentText, TitleText } from "@src/components/atoms"; 4 | import { AnswerType } from "@src/types"; 5 | import * as Styled from "./styled"; 6 | 7 | interface Props { 8 | postAnswer: AnswerType; 9 | } 10 | 11 | const ProfileAnswer: FunctionComponent = ({ postAnswer }) => { 12 | return ( 13 | 14 | 15 | 21 | 22 | ); 23 | }; 24 | 25 | export default ProfileAnswer; 26 | -------------------------------------------------------------------------------- /frontend/src/components/templates/ResisterQuestion/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Container = styled.form` 4 | width: 100%; 5 | max-width: 1200px; 6 | margin-left: auto; 7 | margin-right: auto; 8 | `; 9 | 10 | export const TitleContainer = styled.div` 11 | margin-top: 20px; 12 | margin-bottom: 40px; 13 | `; 14 | 15 | export const TagContainer = styled.div` 16 | margin-bottom: 20px; 17 | `; 18 | export const LiveContainer = styled.div` 19 | display: flex; 20 | margin-bottom: 20px; 21 | align-items: center; 22 | & > h2 { 23 | margin: 0; 24 | margin-right: 20px; 25 | margin-bottom: 5px; 26 | font-weight: normal; 27 | } 28 | `; 29 | export const SubmitContainer = styled.div` 30 | width: 100%; 31 | margin-top: 30px; 32 | `; 33 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import Image from "next/image"; 3 | 4 | import * as Styled from "./styled"; 5 | import { Text } from "@components/atoms"; 6 | interface Props { 7 | src: string; 8 | text: string; 9 | onClick: (event: React.MouseEvent) => void; 10 | } 11 | 12 | const ProfileHeader: FunctionComponent = ({ src, text, onClick }) => { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default ProfileHeader; 26 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ProfileDropdown } from "./ProfileDropdown"; 2 | export { default as ProfileHeader } from "./ProfileHeader"; 3 | export { default as ProfileSummary } from "./ProfileSummary"; 4 | export { default as TagSearch } from "./TagSearch"; 5 | export { default as LoginModal } from "./LoginModal"; 6 | export { default as QuestionTitle } from "./QuestionTitle"; 7 | export { default as IconWithNumber } from "./IconWithNumber"; 8 | export { default as ViewsAndComment } from "./ViewsAndComment"; 9 | export { default as TagList } from "./TagList"; 10 | export { default as Vote } from "./Vote"; 11 | export { default as TagInput } from "./TagInput"; 12 | export { default as Modal } from "./Modal"; 13 | export { default as AudioStreamProfile } from "./AudioStreamProfile"; 14 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/AudioStreamProfileList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useState } from "react"; 2 | 3 | import { AudioStreamProfile } from "@src/components/molecules"; 4 | import * as Styled from "./styled"; 5 | 6 | interface Props { 7 | profiles: any; 8 | } 9 | 10 | const AudioStreamProfileList: FunctionComponent = ({ profiles }) => { 11 | return ( 12 | 13 | {Object.values(profiles).map((elm: any, idx) => { 14 | const user = elm.user; 15 | return ( 16 | 21 | ); 22 | })} 23 | 24 | ); 25 | }; 26 | 27 | export default AudioStreamProfileList; 28 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Indicator/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | interface StyleProps { 5 | title: string; 6 | bgColor: string; 7 | width: string; 8 | height: string; 9 | } 10 | 11 | const types: { [key: string]: StyleProps } = { 12 | online: { 13 | title: "현재 라이브 답변을 대기중에 있습니다.", 14 | bgColor: "#6cc16f", 15 | width: "16px", 16 | height: "16px", 17 | }, 18 | offline: { 19 | title: "현재 질문자가 오프라인 상태입니다.", 20 | bgColor: "#e26a61", 21 | width: "16px", 22 | height: "16px", 23 | }, 24 | }; 25 | 26 | const Indicator: FunctionComponent<{ type: string }> = ({ type }) => { 27 | const styleProps = types[type]; 28 | return ; 29 | }; 30 | 31 | export default Indicator; 32 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ViewsAndComment/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | import { IconWithNumber } from "@components/molecules"; 5 | 6 | interface Props { 7 | viewCount: number; 8 | commentCount: number; 9 | } 10 | 11 | const ViewsAndComment: FunctionComponent = ({ 12 | viewCount, 13 | commentCount, 14 | }) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default ViewsAndComment; 28 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/SideBar/live.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/pages/WritePage/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useState } from "react"; 2 | import styled from "styled-components"; 3 | import ResisterQuestion from "../../components/templates/ResisterQuestion"; 4 | import { Header } from "../../components/organisms"; 5 | interface Props {} 6 | 7 | const MainContainer = styled.main` 8 | display: flex; 9 | width: 800px; 10 | padding-top: 20px; 11 | padding-bottom: 100px; 12 | max-width: 1200px; 13 | margin-left: auto; 14 | margin-right: auto; 15 | `; 16 | 17 | const WritePage: FunctionComponent = () => { 18 | return ( 19 | <> 20 |
    {}} /> 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default WritePage; 29 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Chart/Chart.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Chart from "./index"; 3 | 4 | export default { 5 | title: "Atoms/Chart", 6 | component: Chart, 7 | }; 8 | 9 | const data = { 10 | labels: ["React", "Javascript", "HTML"], 11 | datasets: [ 12 | { 13 | label: "# of Votes", 14 | data: [12, 19, 3], 15 | backgroundColor: [ 16 | "rgba(255, 99, 132, 0.2)", 17 | "rgba(54, 162, 235, 0.2)", 18 | "rgba(255, 206, 86, 0.2)", 19 | ], 20 | borderColor: [ 21 | "rgba(255, 99, 132, 1)", 22 | "rgba(54, 162, 235, 1)", 23 | "rgba(255, 206, 86, 1)", 24 | ], 25 | borderWidth: 1, 26 | }, 27 | ], 28 | }; 29 | 30 | export const DOUGHNUT = () => { 31 | return ; 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LeaderBoard/trophy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Chart/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import { ChartData } from "chart.js"; 3 | import { Bar, Doughnut } from "react-chartjs-2"; 4 | 5 | import * as Styled from "./styled"; 6 | 7 | interface ChartProps { 8 | type: String; 9 | data: ChartData<"doughnut"> & ChartData<"bar">; 10 | } 11 | 12 | const Chart: FunctionComponent = ({ type, data }) => { 13 | if (type === "Doughnut") 14 | return ( 15 | 16 | 17 | 18 | ); 19 | else if (type === "Bar") { 20 | return ( 21 | 22 | 23 | 24 | ); 25 | } 26 | return <>; 27 | }; 28 | 29 | export default Chart; 30 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/Question/Question.stories.tsx: -------------------------------------------------------------------------------- 1 | import Question from "."; 2 | 3 | export default { 4 | Component: Question, 5 | title: "Organisms/Question", 6 | }; 7 | 8 | export const example = () => { 9 | const onClick = () => {}; 10 | const data = { 11 | __typename: "PostQuestion", 12 | id: 1, 13 | title: "리액트 관련 질문이 있습니다.", 14 | realtimeShare: false, 15 | author: { 16 | id: "1", 17 | profileUrl: "https://avatars.githubusercontent.com/u/67536413", 18 | score: 23, 19 | username: "안녕", 20 | __typename: "User", 21 | }, 22 | desc: "내용", 23 | tags: [ 24 | { 25 | __typename: "Tag", 26 | name: "react.js", 27 | }, 28 | ], 29 | viewCount: 1, 30 | thumbupCount: 2, 31 | }; 32 | return ; 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/ProfileQuestionSummary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import { TitleText } from "@src/components/atoms"; 4 | import { QuestionType } from "@src/types"; 5 | import { QuestionList } from "@src/components/templates"; 6 | import * as Styled from "./styled"; 7 | 8 | interface Props { 9 | postQuestions: QuestionType[]; 10 | } 11 | 12 | const ProfileQuestionSummary: FunctionComponent = ({ 13 | postQuestions, 14 | }) => { 15 | return ( 16 | 17 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default ProfileQuestionSummary; 27 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/RealTimeEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import React, { FunctionComponent } from "react"; 3 | import * as Socket from "socket.io-client"; 4 | 5 | import { QuestionDetailType } from "@src/types"; 6 | 7 | const WrappedEditor = dynamic(() => import("./WrappedEditor"), { 8 | ssr: false, 9 | }); 10 | 11 | interface CodeListType { 12 | language: string; 13 | code: string; 14 | } 15 | 16 | const RealTimeEditor: FunctionComponent<{ 17 | question: QuestionDetailType; 18 | socket: Socket.Socket; 19 | setCodeList: (value: CodeListType[]) => void; 20 | }> = ({ question, socket, setCodeList }) => { 21 | return ( 22 | 27 | ); 28 | }; 29 | 30 | export default RealTimeEditor; 31 | -------------------------------------------------------------------------------- /frontend/src/components/templates/QuestionList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | import { Question } from "@components/organisms"; 5 | import { QuestionType } from "@src/types"; 6 | 7 | interface Props { 8 | questions: QuestionType[]; 9 | showProfile?: boolean; 10 | } 11 | 12 | const SearchResults: FunctionComponent = ({ 13 | questions, 14 | showProfile = true, 15 | }) => { 16 | return ( 17 | 18 | {questions.map((question) => { 19 | return ( 20 | 21 | 22 | 23 | ); 24 | })} 25 | 26 | ); 27 | }; 28 | 29 | export default SearchResults; 30 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Button } from "./Button"; 2 | export { default as Chart } from "./Chart"; 3 | export { default as ContentText } from "./ContentText"; 4 | export { default as HeaderText } from "./HeaderText"; 5 | export { default as Indicator } from "./Indicator"; 6 | export { default as Input } from "./Input"; 7 | export { default as SideTag } from "./SideTag"; 8 | export { default as Switch } from "./Switch"; 9 | export { default as Text } from "./Text"; 10 | export { default as TitleText } from "./TitleText"; 11 | export { default as Logo } from "./Logo"; 12 | export { default as Tag } from "./Tag"; 13 | export { default as MDEditor } from "./MDEditor"; 14 | export { default as MDViewer } from "./MDViewer"; 15 | export { default as TitleInput } from "./TitleInput"; 16 | export { default as LiveAudio } from "./LiveAudio"; 17 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ExitCheckModalWapper/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ExitCheckModal = styled.div` 4 | position: absolute; 5 | z-index: 1500; 6 | background: white; 7 | border-radius: 10px; 8 | border: 1px solid gray; 9 | top: 50%; 10 | left: 50%; 11 | width: 460px; 12 | height: 200px; 13 | transform: translate(-50%, -50%); 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | `; 19 | 20 | export const ExitCheckModalWrapper = styled.div` 21 | position: absolute; 22 | z-index: 1000; 23 | top: 0; 24 | left: 0; 25 | width: 100%; 26 | height: 100%; 27 | background: rgba(0, 0, 0, 0.3); 28 | visibility: hidden; 29 | `; 30 | 31 | export const ButtonWapper = styled.div` 32 | display: flex; 33 | gap: 10px; 34 | `; 35 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Button/button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Button from "./index"; 3 | import userImg from "./user.png"; 4 | import logoutImg from "./logout.png"; 5 | 6 | export default { 7 | title: "Atoms/Button", 8 | component: Button, 9 | }; 10 | 11 | export const Default = () => { 12 | const [text, setText] = useState("로그아웃"); 13 | const onClick = () => { 14 | setText((text) => (text === "로그아웃" ? "프로필" : "로그아웃")); 15 | }; 16 | 17 | return ( 18 | 27 | 28 | 34 | 35 | ); 36 | }; 37 | 38 | export default ProfileDropDown; 39 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SideTag/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import Image from "next/image"; 3 | 4 | import * as Styled from "./styled"; 5 | import deleteImg from "./delete.svg"; 6 | interface Props { 7 | type: string; 8 | text: string; 9 | onDelete: (value: string) => void; 10 | } 11 | 12 | interface StyleProps { 13 | tagBgColor: string; 14 | textColor: string; 15 | deleteBgColor: string; 16 | } 17 | 18 | const types: { [key: string]: StyleProps } = { 19 | Default: { 20 | tagBgColor: "#94D3CC", 21 | textColor: "white", 22 | deleteBgColor: "#fc7047", 23 | }, 24 | }; 25 | 26 | const SideTag: FunctionComponent = ({ type, text, onDelete }) => { 27 | const styleProps = types[type]; 28 | return ( 29 | 30 | {text} 31 | onDelete(text)}> 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default SideTag; 39 | -------------------------------------------------------------------------------- /backend/test/integration/connection.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { 3 | ConnectionOptions, 4 | createConnection, 5 | getConnection, 6 | getConnectionManager, 7 | } from "typeorm"; 8 | const TEST_MYSQL_OPT: ConnectionOptions = require("../../ormconfig.json")[ 9 | "test-mysql" 10 | ]; 11 | 12 | export default { 13 | conn: null, 14 | 15 | async connectIfNotExists() { 16 | if (!getConnectionManager().has("default")) { 17 | this.conn = await createConnection(TEST_MYSQL_OPT); 18 | } 19 | return this.conn; 20 | }, 21 | 22 | async clear() { 23 | const connection = getConnection(); 24 | const entities = connection.entityMetadatas; 25 | 26 | await Promise.all(entities.map((e) => this.deleteAll(e.name))); 27 | }, 28 | 29 | async deleteAll(entityName: string) { 30 | const connection = getConnection(); 31 | const repository = connection.getRepository(entityName); 32 | return repository.delete({}); 33 | }, 34 | 35 | async disconnect() { 36 | if (getConnectionManager().has("default")) { 37 | await getConnection().close(); 38 | } 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileHeader/ProfileHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ProfileHeader from "./index"; 3 | 4 | export default { 5 | title: "Molecules/ProfileHeader", 6 | component: ProfileHeader, 7 | }; 8 | 9 | export const Default = () => { 10 | const onClick = () => {}; 11 | return ( 12 | <> 13 | 18 | 19 | ); 20 | }; 21 | 22 | export const ShordId = () => { 23 | const onClick = () => {}; 24 | return ( 25 | <> 26 | 31 | 32 | ); 33 | }; 34 | 35 | export const LongId = () => { 36 | const onClick = () => {}; 37 | return ( 38 | <> 39 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LeaderBoard/question.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ProfileSummary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import Router from "next/router"; 3 | import Image from "next/image"; 4 | 5 | import * as Styled from "./styled"; 6 | import { Text } from "@components/atoms"; 7 | import { AuthorType } from "@src/types"; 8 | 9 | interface Props { 10 | author: AuthorType; 11 | } 12 | 13 | const ProfileSummary: FunctionComponent = ({ author }) => { 14 | const onProfileButton = () => { 15 | Router.push(`/profile/${author.id}`); 16 | }; 17 | 18 | return ( 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default ProfileSummary; 39 | -------------------------------------------------------------------------------- /backend/src/repositories/AnswerThumb/AnswerThumbRepositoryImpl.ts: -------------------------------------------------------------------------------- 1 | import { ThumbValue } from "@src/entities/abstract/Thumb"; 2 | import { EntityRepository, Repository } from "typeorm"; 3 | import AnswerThumb from "../../entities/AnswerThumb"; 4 | import AnswerThumbRepository from "./AnswerThumbRepository"; 5 | 6 | @EntityRepository(AnswerThumb) 7 | export default class AnswerThumbRepositoryImpl 8 | extends Repository 9 | implements AnswerThumbRepository 10 | { 11 | public async deleteByAnswerId(answerId: number): Promise { 12 | await this.delete({ postAnswerId: answerId }); 13 | } 14 | 15 | public async exists(answerId: number, userId: number) { 16 | const thumb = await this.findOne({ postAnswerId: answerId, userId }); 17 | 18 | if (thumb) return true; 19 | else return false; 20 | } 21 | 22 | public async addNew( 23 | value: ThumbValue, 24 | answerId: number, 25 | userId: number 26 | ): Promise { 27 | const newThumb = new AnswerThumb(); 28 | newThumb.postAnswerId = answerId; 29 | newThumb.userId = userId; 30 | newThumb.value = value; 31 | return await this.save(newThumb); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Switch/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | type: string; 7 | isChecked: boolean; 8 | setIsChecked: (value: boolean) => void; 9 | } 10 | 11 | interface StyleProps { 12 | _onColor: string; 13 | offColor: string; 14 | } 15 | 16 | const types: { [key: string]: StyleProps } = { 17 | Default: { 18 | offColor: "#dddddd", 19 | _onColor: "#6edc5f", 20 | }, 21 | DarkMode: { 22 | offColor: "#fb4402", 23 | _onColor: "#1f1e26", 24 | }, 25 | }; 26 | 27 | const Switch: FunctionComponent = ({ 28 | type, 29 | isChecked, 30 | setIsChecked, 31 | }) => { 32 | const styleProps = types[type]; 33 | const checkHandler = () => { 34 | setIsChecked(!isChecked); 35 | }; 36 | return ( 37 | 38 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Switch; 51 | -------------------------------------------------------------------------------- /backend/test/unit/repository/AnswerRepo.test.ts: -------------------------------------------------------------------------------- 1 | import AnswerInput from "@src/dto/AnswerInput"; 2 | import PostAnswer from "@src/entities/PostAnswer"; 3 | import AnswerRepositoryImpl from "@src/repositories/Answer/AnswerRepositoryImpl"; 4 | 5 | describe("AnswerRepository", () => { 6 | let instance: AnswerRepositoryImpl; 7 | 8 | beforeEach(() => { 9 | instance = new AnswerRepositoryImpl(); 10 | }); 11 | 12 | it("modify", async () => { 13 | // given 14 | const ANSWER_ID = 1; 15 | const answerInput = new AnswerInput(); 16 | answerInput.desc = "Desc bla bla..."; 17 | 18 | instance.findById = jest 19 | .fn() 20 | .mockImplementation(async (answerId: number) => { 21 | const originAnswer = new PostAnswer(); 22 | originAnswer.id = answerId; 23 | 24 | return originAnswer; 25 | }); 26 | instance.save = jest 27 | .fn() 28 | .mockImplementation(async (answer: PostAnswer) => answer); 29 | 30 | // when 31 | const modifiedAnswer = await instance.modify(ANSWER_ID, answerInput); 32 | 33 | // then 34 | expect(modifiedAnswer.id).toBe(ANSWER_ID); 35 | expect(modifiedAnswer.desc).toBe(answerInput.desc); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /backend/src/repositories/QuestionThumb/QuestionThumbRepositoryImpl.ts: -------------------------------------------------------------------------------- 1 | import { ThumbValue } from "@src/entities/abstract/Thumb"; 2 | import { EntityRepository, Repository } from "typeorm"; 3 | import QuestionThumb from "../../entities/QuestionThumb"; 4 | import QuestionThumbRepository from "./QuestionThumbRepository"; 5 | 6 | @EntityRepository(QuestionThumb) 7 | export default class QuestionThumbRepositoryImpl 8 | extends Repository 9 | implements QuestionThumbRepository 10 | { 11 | public async deleteByQuestionId(questionId: number): Promise { 12 | await this.delete({ postQuestionId: questionId }); 13 | } 14 | 15 | public async exists(questionId: number, userId: number) { 16 | const thumb = await this.findOne({ postQuestionId: questionId, userId }); 17 | 18 | if (thumb) return true; 19 | else return false; 20 | } 21 | 22 | public async addNew( 23 | value: ThumbValue, 24 | questionId: number, 25 | userId: number 26 | ): Promise { 27 | const newThumb = new QuestionThumb(); 28 | newThumb.postQuestionId = questionId; 29 | newThumb.userId = userId; 30 | newThumb.value = value; 31 | return await this.save(newThumb); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Tag/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, MouseEventHandler } from "react"; 2 | 3 | import * as Styled from "./styled"; 4 | 5 | interface Props { 6 | type: string; 7 | name: string; 8 | onClick: MouseEventHandler; 9 | } 10 | 11 | interface StyleProps { 12 | fontSize: string; 13 | bgColor: string; 14 | color: string; 15 | hoverTextColor: string; 16 | hoverBgColor: string; 17 | border: string; 18 | } 19 | 20 | const types: { [key: string]: StyleProps } = { 21 | Default: { 22 | fontSize: "14px", 23 | bgColor: "white", 24 | color: "#F48024", 25 | hoverTextColor: "white", 26 | hoverBgColor: "#F48024", 27 | border: "1px solid #F48024", 28 | }, 29 | Gray: { 30 | fontSize: "14px", 31 | bgColor: "white", 32 | color: "#5C5C5C", 33 | hoverTextColor: "white", 34 | hoverBgColor: "#BCBBBB", 35 | border: "1px solid #BCBBBB", 36 | }, 37 | }; 38 | 39 | const Tag: FunctionComponent = ({ type, name, onClick }) => { 40 | const styleProps = types[type]; 41 | return ( 42 | 43 | {name} 44 | 45 | ); 46 | }; 47 | 48 | export default Tag; 49 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LeaderBoard/thumbsup.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Switch/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | interface SliderProps { 3 | offColor: string; 4 | _onColor: string; 5 | } 6 | export const Container = styled.label` 7 | display: block; 8 | `; 9 | 10 | export const Input = styled.input` 11 | opacity: 0; 12 | position: absolute; 13 | `; 14 | 15 | export const Slider = styled.div` 16 | background-color: ${(props) => props.offColor}; 17 | border-radius: 50px; 18 | cursor: pointer; 19 | display: flex; 20 | align-items: center; 21 | justify-content: space-between; 22 | padding: 5px; 23 | position: relative; 24 | height: 26px; 25 | width: 50px; 26 | transform: scale(1.5); 27 | transition: background-color 0.4s ease-in-out; 28 | ${Input}:checked + & { 29 | background-color: ${(props) => props._onColor}; 30 | } 31 | `; 32 | 33 | export const Ball = styled.div` 34 | background-color: #fff; 35 | border-radius: 50%; 36 | position: absolute; 37 | top: 2px; 38 | left: 2px; 39 | height: 22px; 40 | width: 22px; 41 | transform: translateX(0px); 42 | transition: transform 0.4s ease-in-out; 43 | ${Input}:checked + ${Slider} & { 44 | transform: translateX(24px); 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/QuestionDetail/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const QuestionDetailContainer = styled.section` 4 | margin-bottom: 50px; 5 | margin-top: 20px; 6 | border: 1px solid #e1e4e8; 7 | border-radius: 5px; 8 | `; 9 | 10 | export const QuestionHeader = styled.div` 11 | padding: 10px 20px; 12 | border-bottom: 1px solid #e1e4e8; 13 | background-color: #f6f8fa; 14 | `; 15 | 16 | export const QuestionHeaderInfo = styled.div` 17 | font-size: 14px; 18 | color: hsl(210deg 8% 45%); 19 | `; 20 | 21 | export const RealTimeRequest = styled.div` 22 | float: right; 23 | `; 24 | 25 | export const ModalWrapper = styled.div` 26 | position: fixed; 27 | z-index: 999; 28 | top: 0; 29 | left: 0; 30 | width: 100vw; 31 | height: 100vh; 32 | background: rgba(0, 0, 0, 0.3); 33 | `; 34 | 35 | export const Modal = styled.div` 36 | position: absolute; 37 | top: 50%; 38 | left: 50%; 39 | transform: translate(-50%, -50%); 40 | min-width: 300px; 41 | min-height: 100px; 42 | padding: 100px; 43 | border-radius: 16px; 44 | background: white; 45 | overflow: auto; 46 | `; 47 | 48 | export const SubButton = styled.span` 49 | cursor: pointer; 50 | `; 51 | -------------------------------------------------------------------------------- /backend/src/repositories/UserHasTag/UserHasTagRepositoryImpl.ts: -------------------------------------------------------------------------------- 1 | import UserHasTag from "../../entities/UserHasTag"; 2 | import { EntityRepository, In, Repository } from "typeorm"; 3 | import UserHasTagRepository from "./UserHasTagRepository"; 4 | 5 | @EntityRepository(UserHasTag) 6 | export default class UserHasTagRepositoryImpl 7 | extends Repository 8 | implements UserHasTagRepository 9 | { 10 | public async findByUserId(userId: number): Promise { 11 | return await this.find({ userId }); 12 | } 13 | 14 | public async addNewRelations( 15 | userId: number, 16 | tagIds: number[] 17 | ): Promise { 18 | const entities: UserHasTag[] = tagIds.map((tagId) => { 19 | const entity = new UserHasTag(); 20 | entity.userId = userId; 21 | entity.tagId = tagId; 22 | 23 | return entity; 24 | }); 25 | 26 | return await this.save(entities); 27 | } 28 | 29 | public async increseAll(userId: number, tagIds: number[]): Promise { 30 | await this.createQueryBuilder() 31 | .update(UserHasTag) 32 | .where({ userId: userId, tagId: In(tagIds) }) 33 | .set({ count: () => "count + 1" }) 34 | .execute(); 35 | 36 | return true; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/Deploy.yml: -------------------------------------------------------------------------------- 1 | name: Main 브랜치 배포 자동화 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | scp: 10 | name: scp files to ncloud instance 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: copy file via ssh password 15 | uses: appleboy/scp-action@master 16 | with: 17 | host: ${{ secrets.REMOTE_HOST }} 18 | username: ${{ secrets.REMOTE_USER }} 19 | password: ${{ secrets.REMOTE_PASSWORD }} 20 | port: ${{ secrets.REMOTE_PORT }} 21 | source: "*" 22 | target: "temp" 23 | overwrite: true 24 | 25 | Docker_compose_up_with_build_option: 26 | needs: scp 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v1 31 | - name: Install Node.js 32 | uses: actions/setup-node@v1 33 | with: 34 | node-version: "14.x" 35 | 36 | - name: Excute deploy script 37 | uses: garygrossgarten/github-action-ssh@release 38 | with: 39 | command: ./deploy.sh 40 | host: ${{ secrets.REMOTE_HOST }} 41 | username: ${{ secrets.REMOTE_USER }} 42 | password: ${{ secrets.REMOTE_PASSWORD }} 43 | port: ${{ secrets.REMOTE_PORT }} 44 | -------------------------------------------------------------------------------- /backend/src/repositories/User/UserRepositoryImpl.ts: -------------------------------------------------------------------------------- 1 | import UserDto from "@src/dto/UserDto"; 2 | import { EntityRepository, Repository } from "typeorm"; 3 | import User from "../../entities/User"; 4 | import UserRepository from "./UserRepository"; 5 | 6 | @EntityRepository(User) 7 | export default class UserRepositoryImpl 8 | extends Repository 9 | implements UserRepository 10 | { 11 | public async findById(id: number): Promise { 12 | const user = await this.findOne({ id }); 13 | 14 | return user; 15 | } 16 | 17 | public async findByUsername(username: string): Promise { 18 | const user = await this.findOne({ username }); 19 | 20 | return user; 21 | } 22 | 23 | public async saveOrUpdate(user: User): Promise { 24 | return await this.save(user); 25 | } 26 | 27 | public async addNew(userDto: UserDto): Promise { 28 | const newUser = new User(); 29 | newUser.id = userDto.id; 30 | newUser.username = userDto.username; 31 | newUser.profileUrl = userDto.profileUrl; 32 | newUser.socialUrl = userDto.socialUrl; 33 | 34 | return await this.save(newUser); 35 | } 36 | 37 | public async findAndOrderByScoreDesc(take: number): Promise { 38 | return await this.find({ take, order: { score: "DESC" } }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/MDViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | // import "prismjs/themes/prism.css"; 3 | // import Prism from "prismjs"; 4 | // import "@toast-ui/editor/dist/toastui-editor.css"; 5 | // import { Viewer } from "@toast-ui/react-editor"; 6 | // import { ViewerProps } from "@toast-ui/react-editor"; 7 | import dynamic from "next/dynamic"; 8 | // const Viewer = dynamic( 9 | // () => import("@toast-ui/react-editor").then((m) => m.Viewer), 10 | // { ssr: false } 11 | // ); 12 | const WrappedViewer = dynamic(() => import("./WrappedViewer"), { 13 | ssr: false, 14 | }); 15 | // const codeSyntaxHighlight = dynamic( 16 | // () => 17 | // import("@toast-ui/editor-plugin-code-syntax-highlight").then( 18 | // (m) => m.codeSyntaxHighlight 19 | // ), 20 | // { ssr: false } 21 | // ); 22 | // const MDViewer: FunctionComponent = () => { 23 | // return ( 24 | // 32 | // ); 33 | // }; 34 | 35 | const MDViewer: FunctionComponent<{ content: string }> = (props) => { 36 | return ; 37 | }; 38 | 39 | export default MDViewer; 40 | -------------------------------------------------------------------------------- /frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "socket.io-client"; 2 | 3 | export interface AuthorType { 4 | __typename: string; 5 | id: string; 6 | username: string; 7 | profileUrl: string; 8 | score: number; 9 | } 10 | 11 | export interface TagType { 12 | __typename: string; 13 | id: string; 14 | name: string; 15 | } 16 | 17 | export interface QuestionType { 18 | id: number | string; 19 | __typename: string; 20 | author: AuthorType; 21 | desc: string; 22 | realtimeShare: boolean; 23 | tags: TagType[]; 24 | title: string; 25 | viewCount: number; 26 | thumbupCount: number; 27 | createdAt: string; 28 | answerCount: number; 29 | } 30 | 31 | export interface QuestionDetailType extends QuestionType { 32 | id: string; 33 | score: number; 34 | answers: AnswerDetailType[]; 35 | adopted: boolean; 36 | } 37 | 38 | export interface DetailType { 39 | id: number; 40 | desc: string; 41 | tags?: TagType[]; 42 | thumbupCount: number; 43 | author: AuthorType; 44 | } 45 | 46 | export interface AnswerDetailType extends DetailType { 47 | createdAt: string; 48 | state: 0 | 1; 49 | } 50 | 51 | export interface AnswerType { 52 | id: number; 53 | userId: number; 54 | desc: string; 55 | thumbupCount: number; 56 | createdAt: string; 57 | state: 0 | 1; 58 | author: AuthorType; 59 | } 60 | -------------------------------------------------------------------------------- /backend/test/unit/service/InjectRepo.ts: -------------------------------------------------------------------------------- 1 | import Container from "typedi"; 2 | import AnswerRepositoryImpl from "@src/repositories/Answer/AnswerRepositoryImpl"; 3 | import AnswerThumbRepositoryImpl from "@src/repositories/AnswerThumb/AnswerThumbRepositoryImpl"; 4 | import QuestionRepositoryImpl from "@src/repositories/Question/QuestionRepositoryImpl"; 5 | import QuestionThumbRepositoryImpl from "@src/repositories/QuestionThumb/QuestionThumbRepositoryImpl"; 6 | import TagRepositoryImpl from "@src/repositories/Tag/TagRepositoryImpl"; 7 | import UserRepositoryImpl from "@src/repositories/User/UserRepositoryImpl"; 8 | import UserHasTagRepositoryImpl from "@src/repositories/UserHasTag/UserHasTagRepositoryImpl"; 9 | 10 | export default () => { 11 | // Variables 12 | Container.set("DEFALUT_TAKE_QUESTIONS_COUNT", 20); 13 | 14 | // Repositories 15 | Container.set("TagRepository", new TagRepositoryImpl()); 16 | Container.set("UserRepository", new UserRepositoryImpl()); 17 | Container.set("QuestionRepository", new QuestionRepositoryImpl()); 18 | Container.set("AnswerRepository", new AnswerRepositoryImpl()); 19 | Container.set("AnswerThumbRepository", new AnswerThumbRepositoryImpl()); 20 | Container.set("QuestionThumbRepository", new QuestionThumbRepositoryImpl()); 21 | Container.set("UserHasTagRepository", new UserHasTagRepositoryImpl()); 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/IconWithNumber/answers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/lib/apolloClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | InMemoryCache, 4 | createHttpLink, 5 | DefaultOptions, 6 | } from "@apollo/client"; 7 | import { setContext } from "@apollo/client/link/context"; 8 | import { getSession } from "next-auth/client"; 9 | import sign from "jwt-encode"; 10 | 11 | const API_ENDPOINT = process.env.NEXT_PUBLIC_APOLLO_API; 12 | 13 | const httpLink = createHttpLink({ 14 | uri: API_ENDPOINT, 15 | }); 16 | 17 | const authLink = setContext(async (_, { headers }) => { 18 | const session = await getSession(); 19 | if (!session || !session.userId) return {}; 20 | 21 | const token = sign( 22 | { userId: session.userId }, 23 | process.env.NEXT_PUBLIC_JWT_KEY as string 24 | ); 25 | 26 | return { 27 | headers: { 28 | authorization: token ? `Bearer ${token}` : "", 29 | }, 30 | }; 31 | }); 32 | 33 | const defaultOptions: DefaultOptions = { 34 | watchQuery: { 35 | fetchPolicy: "network-only", 36 | errorPolicy: "all", 37 | }, 38 | query: { 39 | fetchPolicy: "network-only", 40 | errorPolicy: "all", 41 | }, 42 | mutate: { 43 | errorPolicy: "all", 44 | }, 45 | }; 46 | 47 | const client = new ApolloClient({ 48 | link: authLink.concat(httpLink), 49 | cache: new InMemoryCache(), 50 | defaultOptions: defaultOptions, 51 | }); 52 | 53 | export default client; 54 | -------------------------------------------------------------------------------- /backend/test/unit/service/AnswerService.test.ts: -------------------------------------------------------------------------------- 1 | import AnswerInput from "@src/dto/AnswerInput"; 2 | import PostAnswer from "@src/entities/PostAnswer"; 3 | import AnswerRepository from "@src/repositories/Answer/AnswerRepository"; 4 | import QuestionRepository from "@src/repositories/Question/QuestionRepository"; 5 | import UserRepository from "@src/repositories/User/UserRepository"; 6 | import AnswerServiceImpl from "@src/services/Answer/AnswerServiceImpl"; 7 | import Container from "typedi"; 8 | import InjectRepo from "./InjectRepo"; 9 | 10 | describe("AnswerRepository", () => { 11 | let instance: AnswerServiceImpl; 12 | let answerRepo: AnswerRepository; 13 | 14 | beforeEach(() => { 15 | InjectRepo(); 16 | instance = new AnswerServiceImpl(); 17 | 18 | answerRepo = Container.get("AnswerRepository"); 19 | }); 20 | 21 | it("modify 답변글 내용 수정", async () => { 22 | // given 23 | const answer = new AnswerInput(); 24 | answer.desc = "Desc bla bla ..."; 25 | 26 | answerRepo.findById = jest.fn().mockResolvedValue(new PostAnswer()); 27 | 28 | answerRepo.saveOrUpdate = jest 29 | .fn() 30 | .mockImplementation((a: PostAnswer) => a); 31 | 32 | // when 33 | const modifiedAnswser = await instance.modify(1, answer); 34 | 35 | // then 36 | expect(modifiedAnswser.desc).toBe(answer.desc); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend/src/repositories/Tag/TagRepositoryImpl.ts: -------------------------------------------------------------------------------- 1 | import { createQueryBuilder, EntityRepository, In, Repository } from "typeorm"; 2 | import Tag from "../../entities/Tag"; 3 | import TagRepository from "./TagRepository"; 4 | 5 | @EntityRepository(Tag) 6 | export default class TagRepositoryImpl 7 | extends Repository 8 | implements TagRepository 9 | { 10 | public async findAll(): Promise { 11 | return await this.find(); 12 | } 13 | 14 | public async findById(id: number): Promise { 15 | return await this.findOne({ id }); 16 | } 17 | 18 | public async findByName(name: string): Promise { 19 | return await this.findOne({ name }); 20 | } 21 | 22 | public async findByIds(ids: number[]): Promise { 23 | if (ids.length === 0) return []; 24 | return await this.find({ 25 | where: ids.map((id) => ({ id })), 26 | }); 27 | } 28 | 29 | public async findByQuestionId(questionId: number): Promise { 30 | // https://github.com/typeorm/typeorm/issues/2707 31 | 32 | const tagIds = await createQueryBuilder() 33 | .select("tagId") 34 | .from("question_tags", "q_t") 35 | .where("postQuestionId = :qid", { qid: questionId }) 36 | .getRawMany(); 37 | 38 | const arrTagIds = tagIds.map((tagObj) => tagObj.tagId); 39 | return await this.createQueryBuilder().whereInIds(arrTagIds).getMany(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NextPage } from "next"; 3 | import { useRouter } from "next/router"; 4 | import styled from "styled-components"; 5 | 6 | import { Logo, HeaderText, Button } from "@components/atoms"; 7 | import { Header } from "@components/organisms"; 8 | 9 | const Custom404: NextPage = () => { 10 | const router = useRouter(); 11 | 12 | return ( 13 | <> 14 |
    {}} /> 15 | 16 |
    17 | 18 |
    19 | 20 | 21 |