├── .eslintignore
├── .github
├── PULL_REQUEST_TEMPLATE.md
├── dependabot.yml
└── workflows
│ ├── CD.yml
│ └── CI.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── README.md
├── client
├── .babelrc
├── .eslintrc.json
├── .storybook
│ ├── main.js
│ └── preview.js
├── __test__
│ ├── CustomRender.tsx
│ └── svgTransform.js
├── jest.config.js
├── package.json
├── public
│ ├── favicon.ico
│ ├── fonts
│ │ ├── Pretendard-Bold.woff2
│ │ └── Pretendard-Regular.woff2
│ ├── hyupup-logo.svg
│ ├── icons
│ │ ├── arrow-right.svg
│ │ ├── board-icon.svg
│ │ ├── calendar-icon.svg
│ │ ├── cancel-icon-circled.svg
│ │ ├── cancel-icon.svg
│ │ ├── chevron-down.svg
│ │ ├── delete-icon-red.svg
│ │ ├── delete-icon.svg
│ │ ├── draggable.svg
│ │ ├── drop-icon.svg
│ │ ├── file-copy.svg
│ │ ├── file-icon.svg
│ │ ├── google-icon.svg
│ │ ├── group.svg
│ │ ├── home-active-icon.svg
│ │ ├── home-icon.svg
│ │ ├── image-icon.svg
│ │ ├── info.svg
│ │ ├── meatball-icon.svg
│ │ ├── minus-icon.svg
│ │ ├── more_horiz.svg
│ │ ├── personal-icon.svg
│ │ ├── plus-icon.svg
│ │ ├── project-icon.svg
│ │ ├── search-icon.svg
│ │ ├── setting-active-icon.svg
│ │ ├── setting-icon.svg
│ │ ├── team-icon.svg
│ │ ├── time-icon.svg
│ │ ├── white-add.svg
│ │ ├── work-active-icon.svg
│ │ └── work-icon.svg
│ ├── image
│ │ ├── Boy-0.svg
│ │ ├── Boy-1.svg
│ │ ├── Boy-10.svg
│ │ ├── Boy-11.svg
│ │ ├── Boy-12.svg
│ │ ├── Boy-13.svg
│ │ ├── Boy-14.svg
│ │ ├── Boy-15.svg
│ │ ├── Boy-16.svg
│ │ ├── Boy-2.svg
│ │ ├── Boy-3.svg
│ │ ├── Boy-4.svg
│ │ ├── Boy-5.svg
│ │ ├── Boy-6.svg
│ │ ├── Boy-7.svg
│ │ ├── Boy-8.svg
│ │ ├── Boy-9.svg
│ │ ├── Girl-0.svg
│ │ ├── Girl-1.svg
│ │ ├── Girl-10.svg
│ │ ├── Girl-11.svg
│ │ ├── Girl-12.svg
│ │ ├── Girl-13.svg
│ │ ├── Girl-14.svg
│ │ ├── Girl-15.svg
│ │ ├── Girl-16.svg
│ │ ├── Girl-2.svg
│ │ ├── Girl-3.svg
│ │ ├── Girl-4.svg
│ │ ├── Girl-5.svg
│ │ ├── Girl-6.svg
│ │ ├── Girl-7.svg
│ │ ├── Girl-8.svg
│ │ └── Girl-9.svg
│ └── index.html
├── src
│ ├── App.tsx
│ ├── Router.tsx
│ ├── components
│ │ ├── AvatarForm
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── BackLogItem
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── BackLogTask
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── BackLogTaskContainer
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── CoworkerStatusItem
│ │ │ ├── Avatar
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ └── StatusTitle
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ ├── EpicColorInfo
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── EpicEditModal
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── EpicEntryItem
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── EpicPlaceholder
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── KanbanColumn
│ │ │ ├── KanbanAddBtn
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── KanbanDeleteModal
│ │ │ │ └── index.tsx
│ │ │ ├── KanbanItem
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── KanbanItemInput
│ │ │ │ ├── index.test.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── KanbanModal
│ │ │ ├── KanbanModalTitle
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── KanbanTask
│ │ │ │ ├── index.test.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── TaskItemWithUser
│ │ │ │ └── index.tsx
│ │ │ ├── TaskItemWithoutUser
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ListViewHeader
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ListViewItem
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── LogInForm
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Profile
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ProjectCard
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ProjectCreateForm
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ProjectModal
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ProjectModalItem
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── RoadmapBars
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── RoadmapCalendar
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── RoadmapItem
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── SideBarDropdown
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── SideBarEntry
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── SignUpForm
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── TeamInviteBar
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── TeamItemViewer
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── TeamManagementItem
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── TodoInputBar
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ └── index.ts
│ ├── contexts
│ │ ├── epicContext.tsx
│ │ ├── index.tsx
│ │ ├── socketContext.tsx
│ │ ├── storyContext.tsx
│ │ └── userContext.tsx
│ ├── index.tsx
│ ├── layers
│ │ ├── Backlog
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── CoworkerStatus
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── GroupManagement
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Header
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Kanban
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ListView
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── ProjectManagement
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Roadmap
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── SideBar
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ └── index.ts
│ ├── lib
│ │ ├── api
│ │ │ ├── email.ts
│ │ │ ├── epic.ts
│ │ │ ├── organization.ts
│ │ │ ├── project.ts
│ │ │ ├── story.ts
│ │ │ ├── task.ts
│ │ │ ├── todo.ts
│ │ │ └── user.ts
│ │ ├── common
│ │ │ ├── avatar
│ │ │ │ └── index.ts
│ │ │ ├── link
│ │ │ │ └── Link.tsx
│ │ │ └── message
│ │ │ │ ├── error.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── success.ts
│ │ │ │ └── warning.ts
│ │ ├── design
│ │ │ ├── Button
│ │ │ │ └── index.tsx
│ │ │ ├── DropDown
│ │ │ │ └── index.tsx
│ │ │ ├── Logo
│ │ │ │ └── index.tsx
│ │ │ ├── Modal
│ │ │ │ └── index.tsx
│ │ │ ├── PageIcon
│ │ │ │ └── index.ts
│ │ │ ├── SearchBar
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── Spinner
│ │ │ │ ├── Bars
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── style.ts
│ │ │ │ ├── Circle
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── style.ts
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ └── index.ts
│ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useContextHooks.tsx
│ │ │ ├── useInput.tsx
│ │ │ ├── useOutSideClick.tsx
│ │ │ ├── useSocketReceive.tsx
│ │ │ ├── useSocketSend.tsx
│ │ │ └── useTabs.tsx
│ │ └── utils
│ │ │ ├── bytes.ts
│ │ │ ├── date.ts
│ │ │ ├── drag.ts
│ │ │ ├── epic.ts
│ │ │ ├── sort.ts
│ │ │ └── story.ts
│ ├── pages
│ │ ├── AdminPage
│ │ │ └── index.tsx
│ │ ├── LandingPage
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── LogInPage
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── MainPage
│ │ │ └── index.tsx
│ │ ├── SignUpPage
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── WorkPage
│ │ │ └── index.tsx
│ │ └── index.ts
│ ├── recoil
│ │ ├── calendar
│ │ │ └── atom.ts
│ │ ├── story
│ │ │ ├── atom.ts
│ │ │ ├── index.ts
│ │ │ └── selector.ts
│ │ └── user
│ │ │ ├── atom.ts
│ │ │ ├── index.ts
│ │ │ └── selector.ts
│ ├── setupTests.ts
│ ├── stories
│ │ ├── BarsSpinner.stories.tsx
│ │ ├── Button.stories.tsx
│ │ ├── CircleSpinner.stories.tsx
│ │ ├── DropDown.stories.tsx
│ │ ├── SearchBar.stories.tsx
│ │ └── Spinner.stories.tsx
│ ├── styles
│ │ ├── GlobalStyle.ts
│ │ └── theme.ts
│ └── types
│ │ ├── epic.ts
│ │ ├── image.d.ts
│ │ ├── link.d.ts
│ │ ├── png.d.ts
│ │ ├── project.d.ts
│ │ ├── story.d.ts
│ │ ├── svg.d.ts
│ │ ├── task.d.ts
│ │ ├── users.d.ts
│ │ └── woff2.d.ts
├── tsconfig.json
└── webpack.config.js
├── drop.sql
├── init.sql
├── init_1.sql
├── mock.sql
├── package.json
├── server
├── .eslintrc.json
├── app.ts
├── bin
│ └── www.ts
├── jest.config.js
├── lib
│ ├── index.ts
│ └── types
│ │ ├── index.d.ts
│ │ ├── req-body.d.ts
│ │ ├── session.d.ts
│ │ └── user.d.ts
├── package.json
├── socket.ts
├── src
│ ├── Email
│ │ ├── Email.controller.ts
│ │ ├── Email.router.ts
│ │ └── Email.service.ts
│ ├── Epics
│ │ ├── Epics.controller.ts
│ │ ├── Epics.entity.ts
│ │ └── Epics.router.ts
│ ├── Organizations
│ │ ├── Organizations.controller.ts
│ │ ├── Organizations.entity.ts
│ │ └── Organizations.router.ts
│ ├── Projects
│ │ ├── Projects.controller.ts
│ │ ├── Projects.entity.ts
│ │ └── Projects.router.ts
│ ├── Stories
│ │ ├── Stories.controller.ts
│ │ ├── Stories.entity.ts
│ │ └── Stories.router.ts
│ ├── Tasks
│ │ ├── Tasks.controller.ts
│ │ ├── Tasks.entity.ts
│ │ └── Tasks.router.ts
│ ├── Todo
│ │ ├── Todo.controller.ts
│ │ ├── Todo.entity.ts
│ │ └── Todo.router.ts
│ ├── Users
│ │ ├── Users.controller.ts
│ │ ├── Users.entity.ts
│ │ ├── Users.router.ts
│ │ └── Users.service.ts
│ ├── index.ts
│ └── utils
│ │ ├── authValidator.ts
│ │ └── requestValidator.ts
├── test
│ ├── Email.test.ts
│ ├── Epics.test.ts
│ ├── Organization.test.ts
│ ├── Projects.test.ts
│ ├── Tasks.test.ts
│ ├── Todo.test.ts
│ ├── Users.test.ts
│ └── index.ts
└── tsconfig.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | package.json
2 | node_modules/
3 | webpack.config.js
4 | jest.config.js
5 | __test__/
6 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## 😀 제목
2 |
3 | PR 제목과 똑같이 작성 (태스크 이름)
4 |
5 | ## ⛅️ 내용
6 |
7 | > 이 PR의 작업 요약
8 |
9 | 여기에 작성
10 |
11 | ## 🎸특이사항
12 |
13 | > 리뷰시 참고할만한 내용, 주의깊게 봐줬으면 하는 내용
14 |
15 | 여기에 작성
16 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
--------------------------------------------------------------------------------
/.github/workflows/CD.yml:
--------------------------------------------------------------------------------
1 | name: release-deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - release
7 |
8 | jobs:
9 | build:
10 | name: Build
11 | runs-on: ubuntu-18.04
12 | steps:
13 | - name: deploying remote ssh commands using key
14 | uses: appleboy/ssh-action@master
15 | with:
16 | host: ${{ secrets.HOST }}
17 | username: ${{ secrets.USERNAME }}
18 | key: ${{ secrets.KEY }}
19 | port: ${{ secrets.PORT }}
20 | script: |
21 | cd /home/guest/WEB23-HyupUp
22 | bash deploy.sh
23 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - develop
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-18.04
11 |
12 | strategy:
13 | matrix:
14 | node-version: [16.x]
15 |
16 | steps:
17 | - uses: actions/checkout@v1
18 | - name: Use Node.js ${{ matrix.node-version }}
19 | uses: actions/setup-node@v1
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 | - name: npm install, build, and test
23 | run: |
24 | yarn run ci
25 | yarn run lint
26 | env:
27 | CI: true
28 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .cache
2 | package.json
3 | package-lock.json
4 | public
5 | dist
6 | tsconfig.json
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | semi: true,
4 | useTabs: false,
5 | tabWidth: 2,
6 | trailingComma: 'all',
7 | printWidth: 100,
8 | endOfLine: 'lf',
9 | };
10 |
--------------------------------------------------------------------------------
/client/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/env",
5 | {
6 | "targets": {
7 | "browsers": [">= 5% in KR"]
8 | }
9 | }
10 | ],
11 | "@babel/react",
12 | "@babel/typescript"
13 | ],
14 | "plugins" : ["@babel/proposal-class-properties"]
15 | }
--------------------------------------------------------------------------------
/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "env": {
4 | "browser": true,
5 | "jest": true
6 | },
7 | "plugins": ["@typescript-eslint", "prettier", "react-hooks"],
8 | "extends": [
9 | "eslint:recommended",
10 | "plugin:react/recommended",
11 | "plugin:react-hooks/recommended",
12 | "plugin:@typescript-eslint/recommended",
13 | "prettier"
14 | ],
15 | "rules": {
16 | "@typescript-eslint/no-unused-vars": "warn",
17 | "no-console": "warn",
18 | "react-hooks/exhaustive-deps": "warn"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/client/.storybook/main.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | module.exports = {
3 | stories: ['../src/stories/*.stories.mdx', '../src/stories/*.stories.@(ts|tsx)'],
4 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
5 | webpackFinal: async (config) => {
6 | config.resolve.alias = {
7 | '@': path.resolve(__dirname, '../src'),
8 | '@public': path.resolve(__dirname, '../public'),
9 | };
10 |
11 | return config;
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/client/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from 'styled-components';
2 | import { addDecorator } from '@storybook/react';
3 | import theme from '../src/styles/theme';
4 | import GlobalStyle from '../src/styles/GlobalStyle';
5 |
6 | export const parameters = {
7 | actions: { argTypesRegex: '^on[A-Z].*' },
8 |
9 | controls: {
10 | matchers: {
11 | color: /(background|color)$/i,
12 | date: /Date$/,
13 | },
14 | },
15 | };
16 |
17 | addDecorator((story) => (
18 |
19 |
20 | {story()}
21 |
22 | ));
23 |
--------------------------------------------------------------------------------
/client/__test__/CustomRender.tsx:
--------------------------------------------------------------------------------
1 | import React, { ComponentType, ReactElement } from 'react';
2 | import { render } from '@testing-library/react';
3 | import { RecoilRoot } from 'recoil';
4 | import { ThemeProvider } from 'styled-components';
5 | import theme from 'client/src/styles/theme';
6 | import { StoryProvider } from 'client/src/contexts/storyContext';
7 | import { EpicProvider } from 'client/src/contexts/epicContext';
8 |
9 | const CustomProvider = ({ children }: { children: React.FC }) => {
10 | return (
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | const CustomRender = (ui: ReactElement, options?: any) => {
22 | return render(ui, { wrapper: CustomProvider as ComponentType, ...options });
23 | };
24 |
25 | export default CustomRender;
26 |
--------------------------------------------------------------------------------
/client/__test__/svgTransform.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | process() {
3 | return 'module.exports = {};';
4 | },
5 | getCacheKey() {
6 | return 'svgTransform';
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/client/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | moduleNameMapper: {
4 | '@/(.*)$': '/src/$1',
5 | '@public(.*)$': '/public/$1',
6 | },
7 | transform: {
8 | '^.+\\.tsx?$': 'ts-jest',
9 | '^.+\\.+\\.tsx?$': 'ts-jest',
10 | '^.+\\.svg$': '/__test__/svgTransform.js',
11 | },
12 | testEnvironment: 'jsdom',
13 | verbose: true,
14 | };
15 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/WEB23-HyupUp/957fb22f18db762b821a7b4d602518d1e1036bdf/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/fonts/Pretendard-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/WEB23-HyupUp/957fb22f18db762b821a7b4d602518d1e1036bdf/client/public/fonts/Pretendard-Bold.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Pretendard-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/WEB23-HyupUp/957fb22f18db762b821a7b4d602518d1e1036bdf/client/public/fonts/Pretendard-Regular.woff2
--------------------------------------------------------------------------------
/client/public/icons/arrow-right.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/public/icons/board-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/calendar-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/cancel-icon-circled.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/cancel-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/delete-icon-red.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/delete-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/draggable.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/public/icons/drop-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/file-copy.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/file-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/group.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/home-active-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/home-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/image-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/info.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/meatball-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/minus-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/more_horiz.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/personal-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/plus-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/project-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/search-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/setting-active-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/setting-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/team-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/time-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/white-add.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/work-active-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/icons/work-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | HyupUp
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { RecoilRoot } from 'recoil';
3 | import GlobalStyle from './styles/GlobalStyle';
4 | import { ToastContainer } from 'react-toastify';
5 | import 'react-toastify/dist/ReactToastify.css';
6 | import { BrowserRouter } from 'react-router-dom';
7 | import Router from '@/Router';
8 | import ContextProvider from './contexts';
9 |
10 | function App() {
11 | return (
12 | <>
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | >
23 | );
24 | }
25 |
26 | export default App;
27 |
--------------------------------------------------------------------------------
/client/src/components/AvatarForm/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Button } from '@/lib/design';
4 | import rightArrow from '@public/icons/arrow-right.svg';
5 | import * as S from './style';
6 | import * as avatar from '@/lib/common/avatar';
7 | import { ImageType } from '@/types/image';
8 |
9 | const MAXINDEX = 16;
10 |
11 | const AvatarForm = ({
12 | gender,
13 | setGender,
14 | avatarIndex,
15 | setAvatarIndex,
16 | }: {
17 | gender: string;
18 | setGender: React.Dispatch>;
19 | avatarIndex: number;
20 | setAvatarIndex: React.Dispatch>;
21 | }) => {
22 | const changeGender = () => (gender === 'Boy' ? setGender('Girl') : setGender('Boy'));
23 |
24 | const clickLeft = () =>
25 | !avatarIndex ? setAvatarIndex(MAXINDEX) : setAvatarIndex((prev) => prev - 1);
26 |
27 | const clickRight = () =>
28 | avatarIndex === MAXINDEX ? setAvatarIndex(0) : setAvatarIndex((prev) => prev + 1);
29 |
30 | return (
31 |
32 |
35 |
36 |
37 |
40 |
41 |
44 |
45 | );
46 | };
47 |
48 | export default AvatarForm;
49 |
--------------------------------------------------------------------------------
/client/src/components/AvatarForm/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const AvatarContainer = styled.div`
4 | width: 250px;
5 | background-color: none;
6 |
7 | display: flex;
8 | flex-direction: column;
9 | align-items: center;
10 | `;
11 |
12 | const Avatar = styled.img`
13 | width: 250px;
14 | height: 250px;
15 |
16 | margin-bottom: 10px;
17 | border: 1px solid black;
18 | border-radius: 2000px;
19 | `;
20 |
21 | const AvatarSelectContainer = styled.div`
22 | width: 50%;
23 |
24 | display: flex;
25 | align-items: center;
26 | justify-content: space-between;
27 | `;
28 |
29 | const LeftArrow = styled.img`
30 | width: 80px;
31 | height: 80px;
32 |
33 | color: ${({ theme }) => theme.color.gray300};
34 |
35 | transform: rotateY(180deg);
36 | `;
37 |
38 | const RightArrow = styled.img`
39 | width: 80px;
40 | height: 80px;
41 |
42 | color: ${({ theme }) => theme.color.gray300};
43 | `;
44 |
45 | export { Avatar, AvatarContainer, AvatarSelectContainer, LeftArrow, RightArrow };
46 |
--------------------------------------------------------------------------------
/client/src/components/BackLogItem/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, Suspense } from 'react';
2 | import { useRecoilRefresher_UNSTABLE } from 'recoil';
3 | import S from './style';
4 | import arrow from '@public/icons/chevron-down.svg';
5 |
6 | import { tasksSelector } from '@/recoil/story';
7 | import { Spinner } from '@/lib/design';
8 |
9 | const BackLogTaskContainer = React.lazy(() => import('../BackLogTaskContainer'));
10 |
11 | const BackLogItem = ({ name, id }: { name: string; id: number }) => {
12 | const [clicked, setClicked] = useState(false);
13 |
14 | const refresh = useRecoilRefresher_UNSTABLE(tasksSelector(id));
15 | useEffect(() => {
16 | return refresh();
17 | }, [refresh]);
18 |
19 | const clickEventListener = () => {
20 | const newClicked = !clicked;
21 | setClicked(newClicked);
22 | if (!newClicked) return;
23 | };
24 |
25 | return (
26 |
27 |
28 | {name}
29 |
30 |
31 |
32 |
33 | {clicked && (
34 | }>
35 |
36 |
37 | )}
38 |
39 | );
40 | };
41 |
42 | export default BackLogItem;
43 |
--------------------------------------------------------------------------------
/client/src/components/BackLogItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const ItemContainer = styled.div`
4 | width: 705px;
5 | height: 70px;
6 |
7 | display: flex;
8 | flex-direction: row;
9 | align-items: center;
10 | justify-content: space-between;
11 |
12 | margin-top: 8px;
13 | margin-bottom: 8px;
14 | border-radius: 8px;
15 | background-color: ${({ theme }) => theme.color.white};
16 | `;
17 |
18 | const StoryText = styled.span`
19 | margin-left: 30px;
20 |
21 | font: ${({ theme }) => theme.font.body_medium};
22 | `;
23 |
24 | interface IsClick {
25 | click: boolean;
26 | }
27 |
28 | const ToggleImg = styled.img`
29 | width: 25px;
30 | height: 25px;
31 |
32 | transform: ${(props) => (props.click ? 'rotate(0deg)' : 'rotate(90deg)')};
33 | transition-duration: ${(props) => (props.click ? '0.1s' : '0.1s')};
34 | `;
35 |
36 | const ToggleButton = styled.button`
37 | position: relative;
38 | right: 10px;
39 | `;
40 |
41 | export default {
42 | ItemContainer,
43 | StoryText,
44 | ToggleImg,
45 | ToggleButton,
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/components/BackLogTask/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as S from './style';
3 |
4 | interface BackLogTaskProps {
5 | name: string;
6 | task: string;
7 | imageURL: string;
8 | }
9 |
10 | const BackLogTask = ({ name, task, imageURL }: BackLogTaskProps) => {
11 | return (
12 |
13 | {task}
14 |
15 |
16 | {name}
17 |
18 |
19 | );
20 | };
21 |
22 | export default BackLogTask;
23 |
--------------------------------------------------------------------------------
/client/src/components/BackLogTask/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const ItemContainer = styled.ul`
4 | width: 705px;
5 | height: 70px;
6 |
7 | display: flex;
8 | flex-direction: row;
9 | align-items: center;
10 | justify-content: space-between;
11 |
12 | background-color: ${({ theme }) => theme.color.gray200};
13 | `;
14 |
15 | const Text = styled.span`
16 | margin-left: 30px;
17 |
18 | font: ${({ theme }) => theme.font.body_medium};
19 | `;
20 |
21 | const UserProfile = styled.div`
22 | display: flex;
23 | flex-direction: row;
24 | align-items: center;
25 | justify-content: space-between;
26 |
27 | min-width: 150px;
28 | margin-right: 30px;
29 | `;
30 |
31 | const Avatar = styled.img`
32 | width: 45px;
33 | height: 45px;
34 |
35 | border-radius: 25px;
36 | `;
37 |
38 | export { ItemContainer, Text, UserProfile, Avatar };
39 |
--------------------------------------------------------------------------------
/client/src/components/BackLogTaskContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Styled from './style';
3 | import { useRecoilValue } from 'recoil';
4 |
5 | import * as avatar from '@/lib/common/avatar';
6 | import { ImageType } from '@/types/image';
7 | import { tasksSelector } from '@/recoil/story';
8 | import BackLogTask from '@/components/BackLogTask';
9 |
10 | const BackLogTaskContainer = ({ storyId }: { storyId: number }) => {
11 | const tasksState = useRecoilValue(tasksSelector(storyId));
12 | return (
13 |
14 | {tasksState.length ? (
15 | tasksState.map((el) => (
16 |
22 | ))
23 | ) : (
24 |
25 |
26 | 칸반보드의 스토리를 클릭하여 Task를 추가해보세요
27 |
28 |
29 | )}
30 |
31 | );
32 | };
33 |
34 | export default BackLogTaskContainer;
35 |
--------------------------------------------------------------------------------
/client/src/components/BackLogTaskContainer/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | TaskContainer: styled.ul`
5 | animation-duration: 0.3s;
6 | animation-name: slidein;
7 | @keyframes slidein {
8 | from {
9 | opacity: 0;
10 | transform: translateY(-10px);
11 | }
12 |
13 | to {
14 | opacity: 1;
15 | transform: translateY(0px);
16 | }
17 | }
18 | `,
19 | UndefinedItemContainer: styled.ul`
20 | width: 705px;
21 | height: 70px;
22 |
23 | display: flex;
24 | flex-direction: row;
25 | align-items: center;
26 | justify-content: center;
27 |
28 | background-color: ${({ theme }) => theme.color.gray200};
29 | `,
30 |
31 | UndefinedText: styled.span`
32 | font: ${({ theme }) => theme.font.body_regular};
33 | `,
34 | };
35 |
36 | export default Styled;
37 |
--------------------------------------------------------------------------------
/client/src/components/CoworkerStatusItem/Avatar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as S from './style';
3 |
4 | const Avatar = ({ src, status }: { src: string; status: boolean }) => {
5 | return (
6 |
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default Avatar;
14 |
--------------------------------------------------------------------------------
/client/src/components/CoworkerStatusItem/Avatar/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const AvatarContainer = styled.div`
4 | width: 55px;
5 | height: 49px;
6 |
7 | margin-left: 29px;
8 |
9 | background-color: none;
10 | `;
11 |
12 | const AvatarImage = styled.img`
13 | width: 45px;
14 | height: 45px;
15 |
16 | border-radius: 25px;
17 | `;
18 |
19 | interface UserAvatarProps {
20 | isOnline: boolean;
21 | }
22 |
23 | const AvatarStatus = styled.div`
24 | width: 20px;
25 | height: 20px;
26 |
27 | position: relative;
28 | bottom: 20px;
29 | float: right;
30 |
31 | border: 3px solid ${({ theme }) => theme.color.white};
32 | border-radius: 15px;
33 | background-color: ${({ theme, isOnline }) =>
34 | isOnline ? theme.color.green300 : theme.color.gray300};
35 | `;
36 |
37 | export { AvatarImage, AvatarContainer, AvatarStatus };
38 |
--------------------------------------------------------------------------------
/client/src/components/CoworkerStatusItem/StatusTitle/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import group from '@public/icons/group.svg';
3 | import * as S from './style';
4 |
5 | const StatusTitle = () => {
6 | return (
7 |
8 |
9 | {'출근한 동료들'}
10 |
11 | );
12 | };
13 |
14 | export default StatusTitle;
15 |
--------------------------------------------------------------------------------
/client/src/components/CoworkerStatusItem/StatusTitle/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Icon = styled.img`
4 | width: 25px;
5 | height: 25px;
6 | margin-right: 15px;
7 | `;
8 |
9 | const Title = styled.span`
10 | font: ${({ theme }) => theme.font.bold_medium};
11 | color: ${({ theme }) => theme.color.gray400};
12 | `;
13 |
14 | const TitleContainer = styled.div`
15 | display: flex;
16 | align-items: center;
17 |
18 | width: 310px;
19 | height: 35px;
20 | margin-bottom: 20px;
21 | `;
22 |
23 | export { Icon, Title, TitleContainer };
24 |
--------------------------------------------------------------------------------
/client/src/components/EpicColorInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import S from './style';
3 | import infoIcon from '@public/icons/info.svg';
4 |
5 | const EpicColorInfo = () => {
6 | const [showInfo, setShowInfo] = useState(false);
7 | const handleOver = () => {
8 | setShowInfo(true);
9 | };
10 | const handleOut = () => {
11 | setShowInfo(false);
12 | };
13 |
14 | return (
15 |
16 |
17 |
18 |
19 | 회색 막대는 연동된 스토리가 없는
20 | 에픽입니다.
21 |
22 |
23 | 빨간색 막대는 연동된 스토리들이 모두
24 | 진행중이 아닌 에픽입니다.
25 |
26 |
27 | 초록색 막대는 연동된 스토리들이 하나라도
28 | 진행중인 에픽입니다.
29 |
30 |
31 | 파란색 막대는 연동된 스토리들이 모두 완료된
32 | 에픽입니다.
33 |
34 |
35 |
36 | );
37 | };
38 | export default EpicColorInfo;
39 |
--------------------------------------------------------------------------------
/client/src/components/EpicColorInfo/style.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 | import { RoadmapBarsStatus } from '@/types/epic';
3 | import { statusToColor } from '@/components/RoadmapItem/style';
4 |
5 | const fadein = keyframes`
6 | from {
7 | opacity: 0;
8 | }
9 | to {
10 | opcaity: 0.8;
11 | }
12 | `;
13 |
14 | const S = {
15 | Container: styled.div`
16 | position: relative;
17 |
18 | width: 25px;
19 | height: 25px;
20 | `,
21 | TooltipContainer: styled.div<{ hidden: boolean }>`
22 | position: absolute;
23 | display: ${({ hidden }) => (hidden ? 'flex' : 'none')};
24 | flex-direction: column;
25 | top: 0px;
26 | left: -458px;
27 |
28 | min-width: 450px;
29 | padding: 16px;
30 |
31 | z-index: 9;
32 | opacity: 0.9;
33 | background-color: ${({ theme }) => theme.color.gray400};
34 | border-radius: 8px;
35 | color: ${({ theme }) => theme.color.white};
36 | animation: ${fadein} 0.1s ease-in-out;
37 | `,
38 | Line: styled.div`
39 | padding: 8px;
40 | `,
41 | Emphasize: styled.span<{ status: RoadmapBarsStatus }>`
42 | color: ${({ theme, status }) => statusToColor(status, theme.color)};
43 | font: ${({ theme }) => theme.font.bold_regular};
44 | `,
45 | };
46 |
47 | export default S;
48 |
--------------------------------------------------------------------------------
/client/src/components/EpicEditModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import S from './style';
3 | import { EpicType } from '@/types/epic';
4 | import { getYMD } from '@/lib/utils/date';
5 | import { Modal } from '@/lib/design';
6 |
7 | interface EpicEditModalProps {
8 | title?: string;
9 | showEditModal: boolean;
10 | setShowModal: React.Dispatch>;
11 | epicData: EpicType;
12 | value: string;
13 | handleChange: (e: React.ChangeEvent) => void;
14 | handleFormSubmit: (e: React.FormEvent, datePair: { startDate: Date; endDate: Date }) => void;
15 | }
16 |
17 | const formatDate = (date: Date) => {
18 | const { year, month, day } = getYMD(date);
19 | return `${year}-${(month + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
20 | };
21 |
22 | const EpicEditModal = ({
23 | title,
24 | showEditModal,
25 | setShowModal,
26 | epicData,
27 | value,
28 | handleChange,
29 | handleFormSubmit,
30 | }: EpicEditModalProps) => {
31 | const [startDate, setStartDate] = useState(epicData.startAt);
32 | const [endDate, setEndDate] = useState(epicData.endAt);
33 |
34 | useEffect(() => {
35 | setStartDate(epicData.startAt);
36 | setEndDate(epicData.endAt);
37 | }, [epicData]);
38 |
39 | return (
40 | setShowModal(false)}
45 | onClickOk={(e) => handleFormSubmit(e, { startDate, endDate })}
46 | >
47 | handleFormSubmit(e, { startDate, endDate })}>
48 |
55 | {[
56 | { label: '시작일', date: startDate, setDate: setStartDate },
57 | { label: '종료일', date: endDate, setDate: setEndDate },
58 | ].map((obj, i) => (
59 |
60 | {obj.label}
61 | obj.setDate(e.target.valueAsDate ?? obj.date)}
65 | />
66 |
67 | ))}
68 |
69 |
70 | );
71 | };
72 |
73 | export default EpicEditModal;
74 |
--------------------------------------------------------------------------------
/client/src/components/EpicEditModal/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Form: styled.form`
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: center;
8 | align-items: center;
9 |
10 | min-width: 350px;
11 | padding: 16px;
12 | margin-bottom: 16px;
13 | `,
14 | Input: styled.input<{ isTitle?: boolean }>`
15 | width: 100%;
16 | padding: 8px 16px;
17 | margin-bottom: ${({ isTitle }) => (isTitle ? '16px' : '0')};
18 |
19 | background-color: ${({ theme }) => theme.color.gray100};
20 | color: ${({ theme }) => theme.color.gray400};
21 | border-radius: 8px;
22 |
23 | font: ${({ theme, isTitle }) => (isTitle ? theme.font.body_medium : theme.font.body_regular)};
24 |
25 | &::placeholder {
26 | color: ${({ theme }) => theme.color.gray300};
27 | }
28 | `,
29 | Label: styled.label`
30 | display: flex;
31 | align-items: center;
32 |
33 | font: ${({ theme }) => theme.font.bold_regular};
34 | word-break: keep-all;
35 |
36 | & + & {
37 | margin-top: 8px;
38 | }
39 |
40 | & input {
41 | margin-left: 16px;
42 | cursor: text;
43 |
44 | &::-webkit-calendar-picker-indicator {
45 | cursor: pointer;
46 | }
47 | }
48 | `,
49 | };
50 |
51 | export default S;
52 |
--------------------------------------------------------------------------------
/client/src/components/EpicEntryItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const fadein = keyframes`
4 | from {
5 | opacity: 0;
6 | }
7 | to {
8 | opacity: 1;
9 | }
10 | `;
11 |
12 | const S = {
13 | Container: styled.li<{ activated: boolean; isEmpty?: boolean }>`
14 | display: flex;
15 | align-items: center;
16 |
17 | margin: 5px 0;
18 | padding: 20px 0;
19 |
20 | font: ${({ theme }) => theme.font.body_regular};
21 | color: ${({ theme }) => theme.color.gray400};
22 | border-top: ${({ theme, activated }) =>
23 | activated ? `4px solid ${theme.color.blue200}` : `4px solid transparent`};
24 | white-space: nowrap;
25 |
26 | cursor: ${({ isEmpty }) => (isEmpty ? 'auto' : 'grab')};
27 |
28 | &:nth-child(1) {
29 | margin-top: 32px;
30 | padding-top: 32px;
31 | }
32 | `,
33 | DeleteIcon: styled.img<{ showDelete: boolean }>`
34 | display: ${({ showDelete }) => (showDelete ? 'block' : 'none')};
35 | animation: ${fadein} 0.1s ease-in-out;
36 | `,
37 | DeleteConfirm: styled.h4`
38 | margin: 32px;
39 | font: ${({ theme }) => theme.font.bold_medium};
40 | `,
41 | DragIndicator: styled.img<{ showDraggable: boolean }>`
42 | margin-right: 8px;
43 |
44 | transition: opacity 0.1s ease;
45 | opacity: ${({ showDraggable }) => (showDraggable ? 1 : 0)};
46 | `,
47 | };
48 |
49 | export default S;
50 |
--------------------------------------------------------------------------------
/client/src/components/EpicPlaceholder/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { checkStringInput } from '@/lib/utils/bytes';
3 | import { toast } from 'react-toastify';
4 | import { errorMessage } from '@/lib/common/message';
5 | import EpicEditModal from '../EpicEditModal';
6 | import { isLatter, isSameDay } from '@/lib/utils/date';
7 |
8 | interface EpicPlaceholderProps {
9 | visible: boolean;
10 | setVisible: React.Dispatch>;
11 | handleSubmit(epicName: string, datePair: { startDate: Date; endDate: Date }): void;
12 | }
13 |
14 | const EpicPlaceholder = ({ visible, setVisible, handleSubmit }: EpicPlaceholderProps) => {
15 | const [value, setValue] = React.useState('');
16 |
17 | const handleFormSubmit = (
18 | ev: React.FormEvent,
19 | { startDate, endDate }: { startDate: Date; endDate: Date },
20 | ) => {
21 | ev.preventDefault();
22 |
23 | if (!checkStringInput(value)) {
24 | toast.error(errorMessage.EPIC_TITLE_LENGTH_LIMIT);
25 | return;
26 | }
27 |
28 | if (isLatter(startDate, endDate) && !isSameDay(startDate, endDate)) {
29 | toast.error(errorMessage.START_DATE_IS_LATTER);
30 | return;
31 | }
32 |
33 | handleSubmit(value, { startDate, endDate });
34 | setValue('');
35 | };
36 |
37 | const handleChange = (ev: React.ChangeEvent) => {
38 | setValue((ev.target as HTMLInputElement).value);
39 | };
40 |
41 | return (
42 |
51 | );
52 | };
53 |
54 | export default EpicPlaceholder;
55 |
--------------------------------------------------------------------------------
/client/src/components/EpicPlaceholder/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Container: styled.form`
5 | display: flex;
6 | justify-content: center;
7 | align-items: center;
8 |
9 | min-width: 400px;
10 | padding: 32px 16px;
11 | `,
12 | Input: styled.input`
13 | width: 100%;
14 | padding: 8px 16px;
15 | margin-right: 16px;
16 |
17 | background-color: ${({ theme }) => theme.color.gray100};
18 | color: ${({ theme }) => theme.color.gray400};
19 | border-radius: 8px;
20 |
21 | font: ${({ theme }) => theme.font.body_regular};
22 | `,
23 | };
24 |
25 | export default S;
26 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanAddBtn/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useRecoilValue } from 'recoil';
3 | import userAtom from '@/recoil/user';
4 | import { postStory } from '@/lib/api/story';
5 | import { Button } from '@/lib/design';
6 | import { useRecoilState } from 'recoil';
7 | import storyListAtom from '@/recoil/story/atom';
8 | import { useSocketSend } from '@/lib/hooks';
9 | import StyledButtonWrapper from './style';
10 | import produce from 'immer';
11 |
12 | const initialItem = {
13 | order: 0,
14 | name: '',
15 | status: 'TODO',
16 | projectId: null,
17 | epicId: null,
18 | };
19 |
20 | const KanbanAddBtn = () => {
21 | const [storyList, setStoryList] = useRecoilState(storyListAtom);
22 | const userState = useRecoilValue(userAtom);
23 | const emitNewStory = useSocketSend('NEW_STORY');
24 | const orderList = storyList.filter((item) => item.status === 'TODO').map((v) => Number(v.order));
25 | const listLargestOrder = orderList.length ? Math.max(...orderList) + 1 : 0;
26 |
27 | const addStory = async () => {
28 | const storyId = await postStory({
29 | ...initialItem,
30 | order: listLargestOrder,
31 | projectId: userState.currentProjectId,
32 | });
33 | if (storyId === undefined) return;
34 |
35 | setStoryList((prev) =>
36 | produce(prev, (draft) => {
37 | draft.push({
38 | ...initialItem,
39 | order: listLargestOrder,
40 | projectId: userState.currentProjectId,
41 | id: storyId,
42 | });
43 | }),
44 | );
45 | };
46 |
47 | return (
48 |
49 |
52 |
53 | );
54 | };
55 |
56 | export default KanbanAddBtn;
57 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanAddBtn/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const StyledButtonWrapper = styled.div`
4 | button {
5 | font: ${({ theme }) => theme.font.bold_small};
6 | color: ${({ theme }) => theme.color.gray300};
7 | background-color: ${({ theme }) => theme.color.white};
8 | }
9 | `;
10 |
11 | export default StyledButtonWrapper;
12 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanDeleteModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, createContext } from 'react';
2 | import { Modal } from '@/lib/design';
3 | import { deleteStoryWithId } from '@/lib/api/story';
4 | import { useRecoilValue, useSetRecoilState } from 'recoil';
5 | import storyListAtom from '@/recoil/story';
6 | import { useSocketSend } from '@/lib/hooks';
7 | import produce from 'immer';
8 | import userAtom from '@/recoil/user';
9 |
10 | export type ModalContextType = {
11 | setShowModal: (args: boolean) => void;
12 | setDeleteItem: (arg: number) => void;
13 | };
14 |
15 | export const KanbanModalContext = createContext(null);
16 |
17 | const KanbanDeleteModal = ({ children }: { children: React.ReactNode }) => {
18 | const [showModal, setShowModal] = useState(false);
19 | const [shouldDeleteKey, setDeleteItem] = useState(0);
20 | const setStoryList = useSetRecoilState(storyListAtom);
21 | const emitDeleteStory = useSocketSend('DELETE_STORY');
22 | const userState = useRecoilValue(userAtom);
23 |
24 | const deleteStory = async () => {
25 | setStoryList((prev) => produce(prev, (draft) => draft.filter((v) => v.id !== shouldDeleteKey)));
26 | await deleteStoryWithId(shouldDeleteKey);
27 | emitDeleteStory(shouldDeleteKey, userState.currentProjectId);
28 | };
29 |
30 | return (
31 |
32 | setShowModal(false)}
36 | onClickCancel={() => setShowModal(false)}
37 | onClickOk={deleteStory}
38 | >
39 | 스토리를 삭제하시겠습니까?
40 |
41 | {children}
42 |
43 | );
44 | };
45 |
46 | export default KanbanDeleteModal;
47 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import cancelicon from '@public/icons/cancel-icon.svg';
3 |
4 | const Styled = {
5 | KanBanItem: styled.article<{ isDragEnter: boolean; isHover: boolean }>`
6 | width: 90%;
7 | height: 77px;
8 | border-radius: 8px;
9 | position: relative;
10 | display: flex;
11 | cursor: grab;
12 | background-color: ${({ theme, isHover }) =>
13 | isHover ? theme.color.gray100 : theme.color.white};
14 | border-radius: 3px;
15 | border-bottom: ${({ isDragEnter, theme }) => isDragEnter && `4px solid ${theme.color.blue200}`};
16 | border-bottom-left-radius: ${({ isDragEnter }) => isDragEnter && '2px'};
17 | border-bottom-right-radius: ${({ isDragEnter }) => isDragEnter && '2px'};
18 | box-shadow: rgb(15 15 15 / 3%) 0px 0px 0px 0.5px, rgb(15 15 15 / 3%) 0px 2px 3px;
19 | transition: 20ms ease-in 0s;
20 | `,
21 |
22 | CancelIcon: styled.p<{ isHover: boolean }>`
23 | position: absolute;
24 | right: 8px;
25 | width: 15px;
26 | height: 15px;
27 | margin-top: 5px;
28 | border-radius: 50%;
29 | background-image: url(${cancelicon});
30 | background-repeat: no-repeat;
31 | background-position: center;
32 | opacity: ${({ isHover }) => (isHover ? 1 : 0)};
33 | transition: opacity 0.2s ease;
34 | :hover {
35 | cursor: pointer;
36 | }
37 | `,
38 | };
39 |
40 | export default Styled;
41 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanItemInput/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@testing-library/jest-dom/extend-expect';
3 | import { act } from '@testing-library/react';
4 | import userEvent from '@testing-library/user-event';
5 | import CustomRender from 'client/__test__/CustomRender';
6 | import { KanbanItemInput } from '@/components';
7 | import { ItemInput } from '@/types/story';
8 |
9 | const story = {
10 | id: 1,
11 | name: '',
12 | status: 'TODO',
13 | order: 0,
14 | projectId: 1,
15 | epicId: 1,
16 | };
17 |
18 | const epic = {
19 | id: 1,
20 | name: 'EpicTest',
21 | startAt: new Date(),
22 | endAt: new Date(),
23 | order: 0,
24 | projectId: 1,
25 | };
26 |
27 | function renderKanbanItemInput({ story, epic }: ItemInput) {
28 | const kanbanItemInput = CustomRender();
29 |
30 | const itemInput = () => kanbanItemInput.getByPlaceholderText('Type a Todo ...');
31 | const epicText = () => kanbanItemInput.getByText('EpicTest');
32 |
33 | async function clickItemInput() {
34 | await act(async () => {
35 | userEvent.click(itemInput());
36 | });
37 | }
38 |
39 | async function typeTextInput(name: string) {
40 | await act(async () => {
41 | userEvent.type(itemInput(), name);
42 | });
43 | }
44 |
45 | return {
46 | itemInput,
47 | epicText,
48 | clickItemInput,
49 | typeTextInput,
50 | };
51 | }
52 |
53 | describe('', () => {
54 | it('칸반 ItemInput 컴포넌트가 나타나며, Type a Todo Placeholder 를 볼 수 있다', () => {
55 | const { itemInput } = renderKanbanItemInput({ story, epic });
56 |
57 | expect(itemInput()).toBeInTheDocument();
58 | });
59 |
60 | it('에픽이 존재하면 에픽의 이름인 EpicTest 가 나타난다', () => {
61 | const { epicText } = renderKanbanItemInput({ story, epic });
62 |
63 | expect(epicText()).toBeInTheDocument();
64 | });
65 |
66 | it('Type a Todo Placeholder 클릭 시 focus 된다', async () => {
67 | const { itemInput, clickItemInput } = renderKanbanItemInput({ story, epic });
68 |
69 | await clickItemInput();
70 |
71 | expect(itemInput()).toHaveFocus();
72 | });
73 |
74 | it('input 에 StoryTest 를 입력 시 해당 텍스트가 동일하게 나타난다', async () => {
75 | const { itemInput, typeTextInput } = renderKanbanItemInput({ story, epic });
76 |
77 | await typeTextInput('StoryTest');
78 |
79 | expect(itemInput()).toHaveValue('StoryTest');
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanItemInput/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import Styled from './style';
3 | import { useSetRecoilState, useRecoilValue } from 'recoil';
4 | import { useInput } from '@/lib/hooks';
5 | import { updateStoryWithName } from '@/lib/api/story';
6 | import { StoryType } from '@/types/story';
7 | import { EpicType } from '@/types/epic';
8 | import { useSocketSend } from '@/lib/hooks';
9 | import { storyState } from '@/recoil/story';
10 | import userAtom from '@/recoil/user';
11 | import { checkStringInput } from '@/lib/utils/bytes';
12 | import { errorMessage } from '@/lib/common/message';
13 | import { toast } from 'react-toastify';
14 |
15 | const KanbanInput = ({
16 | story,
17 | epic,
18 | isHover,
19 | }: {
20 | story: StoryType;
21 | epic: EpicType | undefined;
22 | isHover: boolean;
23 | }) => {
24 | const { key, value, onChange } = useInput('');
25 | const updateStoryName = useSetRecoilState(storyState(key));
26 | const emitUpdateStory = useSocketSend('UPDATE_STORY');
27 | const userState = useRecoilValue(userAtom);
28 | const inputRef = useRef(null);
29 |
30 | const onBlurUpdateName = async () => {
31 | if (key < 0) return;
32 | if (!checkStringInput(value)) {
33 | toast.error(errorMessage.STORY_TITLE_LENGTH_LIMIT);
34 | return;
35 | }
36 | updateStoryName({ status: 'TODO', id: key, order: story.order, name: value });
37 | await updateStoryWithName({ status: 'TODO', id: key, order: story.order, name: value });
38 | emitUpdateStory(key, userState.currentProjectId);
39 | };
40 |
41 | React.useEffect(() => {
42 | if (inputRef.current === null) return;
43 | inputRef.current.focus();
44 | }, []);
45 |
46 | return (
47 |
48 | 17
53 | ? story.name.slice(0, 18) + '...'
54 | : story.name.slice(0, 18)
55 | : 'Type a Todo ...'
56 | }
57 | // {...value}
58 | data-key={story.id}
59 | onChange={onChange}
60 | onBlur={onBlurUpdateName}
61 | isHover={isHover}
62 | ref={inputRef}
63 | />
64 | {epic ? epic.name : '클릭 후 Epic을 등록하세요'}
65 |
66 | );
67 | };
68 |
69 | export default KanbanInput;
70 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/KanbanItemInput/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | Input: styled.input<{ isHover: boolean }>`
5 | margin-top: 5px;
6 | padding-left: 8px;
7 | padding-top: 5px;
8 | width: 95%;
9 | height: 30px;
10 |
11 | font: ${({ theme }) => theme.font.bold_small};
12 | color: ${({ theme }) => theme.color.gray400};
13 | ::placeholder {
14 | font: ${({ theme }) => theme.font.bold_small};
15 | color: ${({ theme }) => theme.color.gray400};
16 | }
17 | cursor: grab;
18 | background-color: ${({ theme, isHover }) =>
19 | isHover ? theme.color.gray100 : theme.color.white};
20 | transition: 20ms ease-in 0s;
21 | `,
22 |
23 | InputContainer: styled.article`
24 | display: flex;
25 | flex-direction: column;
26 |
27 | p {
28 | font: ${({ theme }) => theme.font.bold_extra_small};
29 | color: ${({ theme }) => theme.color.gray400};
30 | font-size: 11px;
31 | padding: 5px 0 10px 8px;
32 | }
33 | `,
34 | };
35 |
36 | export default Styled;
37 |
--------------------------------------------------------------------------------
/client/src/components/KanbanColumn/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | Column: styled.div`
5 | width: 30%;
6 | height: 100%;
7 |
8 | display: flex;
9 | flex-direction: column;
10 |
11 | align-items: center;
12 | position: relative;
13 |
14 | border-radius: 8px;
15 | background-color: ${({ theme }) => theme.color.white};
16 |
17 | button {
18 | width: 250px;
19 | height: 50px;
20 | color: ${({ theme }) => theme.color.gray400};
21 | }
22 |
23 | article {
24 | margin-top: 10px;
25 | }
26 |
27 | article:nth-child(2) {
28 | margin-top: 0px;
29 | }
30 | `,
31 | KanBanColumnTitle: styled.h4<{ isTopEnter: boolean }>`
32 | width: 90%;
33 | text-align: center;
34 | padding-top: 12px;
35 | padding-bottom: 8px;
36 | font: ${({ theme }) => theme.font.bold_regular};
37 | border-bottom: ${({ isTopEnter, theme }) =>
38 | isTopEnter ? `4px solid ${theme.color.blue200}` : `4px solid transparent`};
39 | `,
40 | };
41 |
42 | export default Styled;
43 |
--------------------------------------------------------------------------------
/client/src/components/KanbanModal/KanbanModalTitle/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import KanbanModalTitleWrapper from './style';
3 | import { useInput } from '@/lib/hooks';
4 | import { StoryType } from '@/types/story';
5 | import { useRecoilValue, useSetRecoilState } from 'recoil';
6 | import { updateStoryWithId } from '@/lib/api/story';
7 | import storyListAtom from '@/recoil/story';
8 | import { useSocketSend } from '@/lib/hooks';
9 | import producer from 'immer';
10 | import userAtom from '@/recoil/user';
11 | import { checkStringInput } from '@/lib/utils/bytes';
12 | import { errorMessage } from '@/lib/common/message';
13 | import { toast } from 'react-toastify';
14 |
15 | const KanbanModalTitle = ({ story }: { story: StoryType }) => {
16 | const { value, onChange } = useInput(story?.name);
17 | const setStoryListState = useSetRecoilState(storyListAtom);
18 | const emitUpdateStory = useSocketSend('UPDATE_STORY');
19 | const userState = useRecoilValue(userAtom);
20 |
21 | const handleInputChange = async () => {
22 | if (!checkStringInput(value)) {
23 | toast.error(errorMessage.STORY_TITLE_LENGTH_LIMIT);
24 | return;
25 | }
26 |
27 | setStoryListState((prev) =>
28 | producer(prev, (draft) => [
29 | ...draft.filter((v) => v.id !== story.id),
30 | { ...story, name: value },
31 | ]),
32 | );
33 |
34 | await updateStoryWithId({ ...story, name: value });
35 | emitUpdateStory(story.id, userState.currentProjectId);
36 | };
37 |
38 | return (
39 |
40 | handleInputChange()}
45 | onChange={onChange}
46 | />
47 |
48 | );
49 | };
50 |
51 | export default KanbanModalTitle;
52 |
--------------------------------------------------------------------------------
/client/src/components/KanbanModal/KanbanModalTitle/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const KanbanModalTitleWrapper = styled.div`
4 | position: relative;
5 | input {
6 | text-align: center;
7 | margin-bottom: 40px;
8 | padding: 20px;
9 | width: 400px;
10 | font: ${({ theme }) => theme.font.bold_large}}
11 | ::placeholder {
12 | font: ${({ theme }) => theme.font.bold_large};
13 | color: ${({ theme }) => theme.color.gray500};
14 | }
15 | }
16 |
17 | img {
18 | opacity: 0;
19 | position: absolute;
20 | top: 22px;
21 | transition: 100ms ease-in 0s;
22 | }
23 |
24 | img:hover {
25 | opacity: 1;
26 | }
27 | `;
28 |
29 | export default KanbanModalTitleWrapper;
30 |
--------------------------------------------------------------------------------
/client/src/components/KanbanModal/KanbanTask/style.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const Styled = {
4 | KanbanTaskWrapper: styled.article`
5 | width: 700px;
6 | height: 70px;
7 | border-radius: 10px;
8 | font: ${({ theme }) => theme.font.bold_regular};
9 | background-color: ${({ theme }) => theme.color.gray100};
10 | display: flex;
11 | justify-content: space-between;
12 | align-items: center;
13 | margin-top: 20px;
14 | padding: 10px 70px 10px 20px;
15 |
16 | h4 {
17 | padding-left: 30px;
18 | }
19 |
20 | span {
21 | font: ${({ theme }) => theme.font.bold_extra_small};
22 | font-size: 14px;
23 | }
24 |
25 | input {
26 | background-color: ${({ theme }) => theme.color.gray100};
27 | font: ${({ theme }) => theme.font.bold_regular};
28 | ::placeholder {
29 | color: ${({ theme }) => theme.color.gray500};
30 | }
31 | width: 500px;
32 | height: 30px;
33 | }
34 | `,
35 | Profile: styled.img`
36 | width: 40px;
37 | height: 40px;
38 | margin-right: 16px;
39 | border-radius: 20px;
40 | `,
41 | MemberContainer: styled.div`
42 | width: 100px;
43 | display: flex;
44 | justify-content: space-between;
45 | align-items: center;
46 | p {
47 | width: 70%;
48 | display: flex;
49 | justify-content: space-around;
50 | align-items: center;
51 | padding-left: 10px;
52 | }
53 | img {
54 | width: 15px;
55 | }
56 |
57 | div > img {
58 | opacity: 0;
59 | }
60 |
61 | ul {
62 | position: absolute;
63 | top: 50px;
64 | right: 12px;
65 | }
66 |
67 | .userImage {
68 | width: 50px;
69 | }
70 | `,
71 | DropdownWrapper: styled.div`
72 | position: relative;
73 | img {
74 | width: 25px;
75 | margin-right: 35px;
76 | }
77 | `,
78 |
79 | DeleteIcon: styled.img<{ showDelete: boolean }>`
80 | padding: 5px;
81 | margin-right: 20px;
82 |
83 | cursor: pointer;
84 | opacity: ${({ showDelete }) => (showDelete ? 1 : 0)};
85 | transition: opacity 0.3s;
86 | `,
87 |
88 | DeleteConfirm: styled.h4`
89 | margin: 32px;
90 | font: ${({ theme }) => theme.font.bold_medium};
91 | `,
92 | };
93 |
94 | export default Styled;
95 |
--------------------------------------------------------------------------------
/client/src/components/KanbanModal/TaskItemWithUser/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { KanbanTaskType } from '@/types/story';
3 | import Styled from '../KanbanTask/style';
4 | import { DropDown } from '@/lib/design';
5 | import { useRecoilValue } from 'recoil';
6 | import { userListAtom } from '@/recoil/user';
7 | import { ImageType } from '@/types/image';
8 | import * as avatar from '@/lib/common/avatar';
9 |
10 | interface TaskItemWithUserType {
11 | taskState: KanbanTaskType;
12 | task: KanbanTaskType;
13 | handleUserSelect: (e: React.MouseEvent) => void;
14 | }
15 |
16 | const TaskItemWithUser = ({ taskState, task, handleUserSelect }: TaskItemWithUserType) => {
17 | const userListState = useRecoilValue(userListAtom);
18 | const userListWithId = userListState.map((value) => {
19 | return { ...value, id: value.index };
20 | });
21 |
22 | return (
23 |
24 |
27 |
34 | {taskState.user ? taskState.user : task.user}
35 |
36 | }
37 | list={userListWithId}
38 | handleClick={handleUserSelect}
39 | />
40 |
41 | );
42 | };
43 |
44 | export default TaskItemWithUser;
45 |
--------------------------------------------------------------------------------
/client/src/components/KanbanModal/TaskItemWithoutUser/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Styled from '../KanbanTask/style';
3 | import { DropDown } from '@/lib/design';
4 | import { useRecoilValue } from 'recoil';
5 | import { userListAtom } from '@/recoil/user';
6 |
7 | type handleClick = (e: React.MouseEvent) => void;
8 |
9 | const TaskItemWithoutUser = ({ handleUserSelect }: { handleUserSelect: handleClick }) => {
10 | const userListState = useRecoilValue(userListAtom);
11 | const userListWithId = userListState.map((value) => {
12 | return { ...value, id: value.index };
13 | });
14 |
15 | return (
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default TaskItemWithoutUser;
23 |
--------------------------------------------------------------------------------
/client/src/components/KanbanModal/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | ContentWrapper: styled.section`
5 | height: 550px;
6 | width: 750px;
7 | overflow-y: scroll;
8 | overflow-x: hidden;
9 | z-index: 999;
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | `,
14 | ControlWrapper: styled.div`
15 | display: flex;
16 | justify-content: flex-end;
17 | align-items: center;
18 | width: 700px;
19 | margin-bottom: 20px;
20 |
21 | p {
22 | font: ${({ theme }) => theme.font.bold_regular};
23 | }
24 |
25 | h4 {
26 | font: ${({ theme }) => theme.font.bold_small};
27 | }
28 |
29 | button {
30 | margin: 0;
31 | margin-left: 50px;
32 | }
33 |
34 | ul {
35 | position: absolute;
36 | top: 30px;
37 | right: -5px;
38 | }
39 | `,
40 | MemberContaienr: styled.div`
41 | p {
42 | margin-right: 40px;
43 |
44 | font: ${({ theme }) => theme.font.bold_regular};
45 | }
46 | `,
47 | };
48 |
49 | export default Styled;
50 |
--------------------------------------------------------------------------------
/client/src/components/ListViewHeader/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Styled from '@/components/ListViewHeader/style';
3 | import { ListState } from '../../layers/ListView';
4 | import { useSocketReceive } from '@/lib/hooks';
5 | import { useRecoilValue } from 'recoil';
6 | import userAtom from '@/recoil/user';
7 | import { toast } from 'react-toastify';
8 | import { successMessage } from '@/lib/common/message';
9 |
10 | type ListProps = {
11 | listState: ListState;
12 | handleListState: (event: React.MouseEvent) => void;
13 | };
14 |
15 | const ListViewHeader = ({ listState, handleListState }: ListProps) => {
16 | const userState = useRecoilValue(userAtom);
17 | useSocketReceive('NEW_TASK', async (userId: number) => {
18 | if (userState.id === userId) toast.success(successMessage.UPDATE_TASK);
19 | });
20 |
21 | return (
22 |
23 |
24 | 전체 업무
25 |
26 |
27 | 개인 업무
28 |
29 |
30 | 팀 업무
31 |
32 |
33 | 완료한 업무
34 |
35 |
36 | );
37 | };
38 |
39 | export default ListViewHeader;
40 |
--------------------------------------------------------------------------------
/client/src/components/ListViewHeader/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | interface StateButtonProps {
4 | active: boolean;
5 | }
6 |
7 | const Styled = {
8 | Container: styled.header`
9 | position: absolute;
10 | display: flex;
11 | align-items: center;
12 |
13 | width: 695px;
14 | height: 75px;
15 |
16 | padding: 0 40px;
17 |
18 | border-radius: 8px;
19 | background-color: white;
20 | `,
21 |
22 | StateButton: styled.button`
23 | height: 100%;
24 |
25 | margin-right: 40px;
26 |
27 | border-bottom: ${({ theme, active }) => (active ? '3px solid ' + theme.color.gray400 : 'none')};
28 |
29 | font: ${({ theme }) => theme.font.body_regular};
30 | `,
31 | };
32 |
33 | export default Styled;
34 |
--------------------------------------------------------------------------------
/client/src/components/ListViewItem/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import projectIcon from '@public/icons/project-icon.svg';
3 | import personalIcon from '@public/icons/personal-icon.svg';
4 | import Styled from '@/components/ListViewItem/style';
5 | import Button from '@/lib/design/Button';
6 | import { AllTask } from '@/types/task';
7 |
8 | interface ListItemProp {
9 | task: AllTask;
10 | onClickMethod: (task: AllTask) => Promise;
11 | }
12 |
13 | const maxViewLength = 50;
14 |
15 | const ListViewItem = ({ task, onClickMethod }: ListItemProp) => {
16 | const buttonComponent = useMemo(() => {
17 | if (!task.status) {
18 | return (
19 |
28 | );
29 | } else if (!task.projectId) {
30 | return (
31 |
34 | );
35 | }
36 | }, [task, onClickMethod]);
37 |
38 | return (
39 |
40 |
41 |
42 | {task.projectId ? 'PROJECT' : 'PERSONAL'}
43 |
44 |
45 | {task.name.length < maxViewLength ? task.name : task.name.slice(0, maxViewLength) + '...'}
46 |
47 | {buttonComponent}
48 |
49 | );
50 | };
51 |
52 | export default ListViewItem;
53 |
--------------------------------------------------------------------------------
/client/src/components/ListViewItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | Container: styled.li`
5 | display: flex;
6 | align-items: center;
7 |
8 | height: 69px;
9 | padding: 0 20px;
10 | margin-bottom: 5px;
11 |
12 | border-radius: 8px;
13 | background-color: white;
14 |
15 | font: ${({ theme }) => theme.font.body_regular};
16 |
17 | &:hover {
18 | background-color: ${({ theme }) => theme.color.gray100};
19 | }
20 | `,
21 | Title: styled.div`
22 | display: flex;
23 | align-items: center;
24 |
25 | width: 120px;
26 | margin-right: 60px;
27 |
28 | overflow-x: hidden;
29 | & img {
30 | margin-right: 10px;
31 | }
32 | & h3 {
33 | font: ${({ theme }) => theme.font.bold_regular};
34 | }
35 | `,
36 | Content: styled.div`
37 | width: 350px;
38 | margin-right: 50px;
39 | `,
40 | };
41 |
42 | export default Styled;
43 |
--------------------------------------------------------------------------------
/client/src/components/LogInForm/index.tsx:
--------------------------------------------------------------------------------
1 | import { logIn } from '@/lib/api/user';
2 | import { Button } from '@/lib/design';
3 | import React from 'react';
4 | import user from '@/recoil/user';
5 | import { useSetRecoilState } from 'recoil';
6 | import * as S from './style';
7 | import { UserState } from '@/contexts/userContext';
8 | import { useInput } from '@/lib/hooks';
9 | import { checkObjectInputNull } from '@/lib/utils/bytes';
10 |
11 | export const LogInForm = () => {
12 | const setUserState = useSetRecoilState(user);
13 |
14 | const { value: email, onChange: onChangeEmail, onReset: onResetEmail } = useInput('');
15 | const { value: password, onChange: onChangePassword, onReset: onResetPassword } = useInput('');
16 |
17 | const onLoginSubmit = async (e: React.FormEvent) => {
18 | e.preventDefault();
19 | if(!checkObjectInputNull({ email, password })) return;
20 | const userData = (await logIn(email, password)) as UserState;
21 | setUserState(userData);
22 | onResetEmail();
23 | onResetPassword();
24 | };
25 | return (
26 |
27 |
33 |
39 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/client/src/components/LogInForm/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const LogInFormContainer = styled.form`
4 | width: 100%;
5 |
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 | justify-content: center;
10 |
11 | padding: 10px;
12 | border-radius: 8px;
13 | `;
14 |
15 | const InputBox = styled.input`
16 | width: 40%;
17 | height: 50px;
18 | margin-bottom: 40px;
19 | padding: 0px 20px;
20 |
21 | border-radius: 8px;
22 | background-color: ${({ theme }) => theme.color.white};
23 | font: ${({ theme }) => theme.font.body_regular};
24 | `;
25 |
26 | export { LogInFormContainer, InputBox };
27 |
--------------------------------------------------------------------------------
/client/src/components/Profile/index.tsx:
--------------------------------------------------------------------------------
1 | import { Styled } from '@/components/Profile/style';
2 | import React, { useState, useRef } from 'react';
3 | import useOutSideClick from '@/lib/hooks/useOutSideClick';
4 | import { useUserDispatch } from '@/lib/hooks/useContextHooks';
5 | import { useSocketSend } from '@/lib/hooks';
6 | import { logOut } from '@/lib/api/user';
7 | import { useRecoilValue } from 'recoil';
8 | import userAtom from '@/recoil/user';
9 | import * as avatar from '@/lib/common/avatar';
10 | import { ImageType } from '@/types/image';
11 |
12 | const Profile = () => {
13 | const [openState, setOpenState] = useState(false);
14 | const userState = useRecoilValue(userAtom);
15 | const userDispatch = useUserDispatch();
16 | const toggleDropDown = () => setOpenState((openState) => !openState);
17 | const handleOutClick = () => setOpenState((openState) => !openState);
18 | const emitLogout = useSocketSend('LOGOUT');
19 | const handleLogout = async () => {
20 | emitLogout(userState.id);
21 | userDispatch({ type: 'LOGOUT' });
22 | await logOut();
23 | location.pathname = '/';
24 | };
25 | const ref = useRef(null);
26 |
27 | useOutSideClick(ref, handleOutClick);
28 | return (
29 |
30 |
31 | {openState && (
32 |
33 |
34 | - {userState.name}
35 | -
36 | 로그아웃
37 |
38 |
39 |
40 | )}
41 |
42 | );
43 | };
44 |
45 | export default Profile;
46 |
--------------------------------------------------------------------------------
/client/src/components/Profile/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Styled = {
4 | Profile: styled.div`
5 | display: flex;
6 | flex-direction: column;
7 | align-items: center;
8 |
9 | width: 10%;
10 | position: relative;
11 |
12 | img {
13 | width: 50px;
14 | height: 50px;
15 | border-radius: 50%;
16 | background-color: black;
17 |
18 | cursor: pointer;
19 | }
20 |
21 | .list-container {
22 | width: 120px;
23 | height: 70px;
24 |
25 | position: absolute;
26 | top: 60px;
27 | right: 35px;
28 |
29 | background-color: ${({ theme }) => theme.color.gray100};
30 | border-radius: 10px;
31 | }
32 |
33 | .dropdown-list {
34 | width: 100%;
35 | height: 80%;
36 |
37 | display: flex;
38 | flex-direction: column;
39 | justify-content: space-around;
40 |
41 | list-style: none;
42 | font: ${({ theme }) => theme.font.bold_extra_small};
43 | }
44 |
45 | li {
46 | padding-top: 15px;
47 | padding-left: 15px;
48 | }
49 |
50 | .logout {
51 | &:hover {
52 | cursor: pointer;
53 | }
54 | }
55 | `,
56 | };
57 |
--------------------------------------------------------------------------------
/client/src/components/ProjectCard/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const getIdColor = (id: number): string => {
4 | return ('#' + Math.round(((id % 50) / 50) * 0xffffff).toString(16)).padEnd(7, '0');
5 | };
6 | const Styled = {
7 | CardWrapper: styled.li`
8 | width: 300px;
9 | height: 200px;
10 | margin: 5px 0 5px 12px;
11 | border-radius: 8px;
12 | background-color: ${({ theme }) => theme.color.white};
13 |
14 | box-shadow: ${({ theme }) => theme.shadow.default};
15 | `,
16 | CardHeader: styled.div`
17 | display: flex;
18 | justify-content: space-between;
19 | align-items: center;
20 |
21 | height: 50px;
22 | padding: 20px;
23 | h3 {
24 | font: ${({ theme }) => theme.font.bold_regular};
25 | }
26 | `,
27 | CardImage: styled.div<{ projectId: number }>`
28 | height: 150px;
29 | border-bottom-right-radius: 8px;
30 | border-bottom-left-radius: 8px;
31 | background: ${({ projectId }) =>
32 | `linear-gradient(45deg, ${getIdColor(projectId)}, ${getIdColor(projectId + 77)})`};
33 | cursor: pointer;
34 | transition: ease-in-out 500ms;
35 | &:hover {
36 | opacity: 0.75;
37 | background: ${({ projectId }) =>
38 | `linear-gradient(60deg, ${getIdColor(projectId)}, ${getIdColor(projectId + 77)})`};
39 | }
40 | `,
41 | };
42 |
43 | export default Styled;
44 |
--------------------------------------------------------------------------------
/client/src/components/ProjectCreateForm/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Styled from '@/components/ProjectCreateForm/style';
3 | import { Button } from '@/lib/design';
4 |
5 | interface Props {
6 | onChange: (e: React.ChangeEvent) => void;
7 | onSubmitNewProject: (e: React.FormEvent) => void;
8 | value: string;
9 | }
10 |
11 | const ProjectCreateForm = ({ onSubmitNewProject, onChange, value }: Props) => {
12 | return (
13 |
14 |
15 |
24 |
25 | );
26 | };
27 |
28 | export default ProjectCreateForm;
29 |
--------------------------------------------------------------------------------
/client/src/components/ProjectCreateForm/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | Form: styled.form`
5 | display: flex;
6 | align-items: center;
7 |
8 | width: 950px;
9 | height: 75px;
10 | padding: 12px;
11 |
12 | background-color: ${({ theme }) => theme.color.gray100};
13 | border-radius: 8px;
14 | `,
15 | Input: styled.input`
16 | width: 760px;
17 | height: 50px;
18 | margin-right: 30px;
19 | padding: 16px;
20 |
21 | border-radius: 8px;
22 |
23 | font: ${({ theme }) => theme.font.body_regular};
24 | `,
25 | };
26 |
27 | export default Styled;
28 |
--------------------------------------------------------------------------------
/client/src/components/ProjectModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Modal } from '@/lib/design';
3 | import Styled from '@/components/ProjectModal/style';
4 | import { useInput } from '@/lib/hooks';
5 | import { ProjectType } from '@/types/project';
6 | import { UserInfoWithProject } from '@/types/users';
7 | import SearchBar from '@/lib/design/SearchBar';
8 |
9 | import { useRecoilValue } from 'recoil';
10 | import { userListAtom } from '@/recoil/user';
11 | import ProjectModalItem from '@/components/ProjectModalItem';
12 |
13 | type ProjectModalProps = {
14 | showProjectModal: boolean;
15 | setShowProjectModal: React.Dispatch>;
16 | project: ProjectType;
17 | };
18 |
19 | const ProjectModal = ({ showProjectModal, setShowProjectModal, project }: ProjectModalProps) => {
20 | const userListState = useRecoilValue(userListAtom);
21 | const [renderUsers, setRenderUsers] = useState([]);
22 | const { value, onChange } = useInput('');
23 | useEffect(() => {
24 | if (value.length === 0) {
25 | setRenderUsers(userListState);
26 | return;
27 | }
28 | setRenderUsers(userListState.filter((item) => new RegExp(value, 'i').test(item.name)));
29 | }, [userListState, value]);
30 |
31 | return (
32 | setShowProjectModal(false)}
37 | size="LARGE"
38 | >
39 |
40 | {
46 | e.preventDefault();
47 | }}
48 | />
49 |
50 | {renderUsers.map((user) => (
51 |
52 | ))}
53 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default ProjectModal;
60 |
--------------------------------------------------------------------------------
/client/src/components/ProjectModal/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | ContentWrapper: styled.div`
5 | width: 800px;
6 | form {
7 | margin-left: auto;
8 | }
9 | `,
10 | UserList: styled.ul`
11 | height: 350px;
12 |
13 | overflow-y: scroll;
14 |
15 | &::-webkit-scrollbar {
16 | display: none;
17 | }
18 | `,
19 | };
20 |
21 | export default Styled;
22 |
--------------------------------------------------------------------------------
/client/src/components/ProjectModalItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import plusIcon from '@public/icons/plus-icon.svg';
3 | import minusIcon from '@public/icons/minus-icon.svg';
4 | import deleteIcon from '@public/icons/delete-icon.svg';
5 |
6 | const Styled = {
7 | ItemWrapper: styled.div<{ isClickedMinus?: boolean }>`
8 | position: relative;
9 | z-index: 300;
10 | transform: ${({ isClickedMinus }) => (isClickedMinus ? 'translateX(-70px)' : 'translateX(0)')};
11 | transition: 500ms ease-in-out;
12 | `,
13 | Button: styled.button<{ isMinus: boolean }>`
14 | width: 20px;
15 | height: 20px;
16 |
17 | background-size: contain;
18 | background-position: center center;
19 | background-image: ${({ isMinus }) => (isMinus ? `url(${minusIcon})` : `url(${plusIcon})`)};
20 | `,
21 | UserItem: styled.li`
22 | position: relative;
23 | `,
24 | DeleteBox: styled.div`
25 | position: absolute;
26 | display: flex;
27 | align-items: center;
28 | z-index: 1;
29 |
30 | top: 0;
31 | right: 0;
32 | width: 100px;
33 | height: 70px;
34 |
35 | border-radius: 8px;
36 | background-color: ${({ theme }) => theme.color.red400};
37 | `,
38 | DeleteButton: styled.button`
39 | margin: 0 20px 0 auto;
40 | width: 25px;
41 | height: 25px;
42 | background-image: url(${deleteIcon});
43 | `,
44 | };
45 |
46 | export default Styled;
47 |
--------------------------------------------------------------------------------
/client/src/components/RoadmapBars/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
2 | import React, { useEffect } from 'react';
3 | import { toast } from 'react-toastify';
4 | import { useRecoilValue } from 'recoil';
5 | import S from './style';
6 | import RoadmapItem from '@/components/RoadmapItem';
7 | import { useEpicState } from '@/lib/hooks/useContextHooks';
8 | import { makeEpicRenderInfo } from '@/lib/utils/epic';
9 | import storyListAtom from '@/recoil/story';
10 | import { errorMessage } from '@/lib/common/message';
11 |
12 | const COLUMNS = 15;
13 |
14 | interface RoadmapBarsProps {
15 | rangeFrom: Date;
16 | rangeTo: Date;
17 | }
18 |
19 | const RoadmapBars = ({ rangeFrom, rangeTo }: RoadmapBarsProps) => {
20 | const epics = useEpicState();
21 | const stories = useRecoilValue(storyListAtom);
22 | const epicRenderInfo = makeEpicRenderInfo(epics, stories, {
23 | rangeFrom,
24 | rangeTo,
25 | columns: COLUMNS,
26 | });
27 |
28 | const handleDocumentDragOver = (e: DragEvent) => e.preventDefault();
29 | const handleDocumentDrop = () => toast.error(errorMessage.EPIC_DRAG_OUT_OF_PLACE);
30 |
31 | useEffect(() => {
32 | document.body.addEventListener('dragover', handleDocumentDragOver);
33 | document.body.addEventListener('drop', handleDocumentDrop);
34 | return () => {
35 | document.body.removeEventListener('dragover', handleDocumentDragOver);
36 | document.body.removeEventListener('drop', handleDocumentDrop);
37 | };
38 | }, []);
39 |
40 | return (
41 | <>
42 |
43 | {epicRenderInfo.map(({ id, length, exceedsLeft, exceedsRight, index, status }) => (
44 |
54 | ))}
55 |
56 | >
57 | );
58 | };
59 |
60 | export default RoadmapBars;
61 |
--------------------------------------------------------------------------------
/client/src/components/RoadmapBars/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Container: styled.div`
5 | display: flex;
6 | flex-direction: column;
7 | `,
8 | };
9 |
10 | export default S;
11 |
--------------------------------------------------------------------------------
/client/src/components/RoadmapItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { RoadmapBarsStatus } from '@/types/epic';
3 |
4 | const HANDLE_WIDTH = '12px';
5 |
6 | export const statusToColor = (
7 | status: RoadmapBarsStatus,
8 | color: { blue300: string; red300: string; green300: string; gray300: string },
9 | ) => {
10 | const { blue300, red300, green300, gray300 } = color;
11 | switch (status) {
12 | case 'NOT_STARTED':
13 | return red300;
14 | case 'STARTED':
15 | return green300;
16 | case 'ALL_DONE':
17 | return blue300;
18 | default:
19 | return gray300;
20 | }
21 | };
22 |
23 | const S = {
24 | Container: styled.div<{ columns: number }>`
25 | display: grid;
26 | grid-template-columns: repeat(${({ columns }) => columns}, 1fr);
27 |
28 | margin: 27px 0;
29 | z-index: 1;
30 | `,
31 | Spacer: styled.div`
32 | width: 100%;
33 | height: 25px;
34 | `,
35 | Bar: styled.div<{ status: RoadmapBarsStatus }>`
36 | position: relative;
37 | display: flex;
38 | justify-content: space-between;
39 |
40 | width: 100%;
41 | height: 25px;
42 |
43 | background-color: ${({ status, theme }) => statusToColor(status, theme.color)};
44 | `,
45 | FrontHandle: styled.div<{ status: RoadmapBarsStatus }>`
46 | display: flex;
47 |
48 | width: ${HANDLE_WIDTH};
49 | height: 100%;
50 |
51 | background-color: ${({ theme }) => theme.color.white};
52 | cursor: col-resize;
53 | z-index: 1;
54 |
55 | &::after {
56 | content: '';
57 | width: 100%;
58 | height: 100%;
59 |
60 | border-radius: 8px 0 0 8px;
61 | background-color: ${({ status, theme }) => statusToColor(status, theme.color)};
62 | }
63 | `,
64 | RearHandle: styled.div<{ status: RoadmapBarsStatus }>`
65 | display: flex;
66 | position: absolute;
67 | right: 0px;
68 |
69 | width: ${HANDLE_WIDTH};
70 | height: 100%;
71 |
72 | background-color: ${({ theme }) => theme.color.white};
73 | cursor: col-resize;
74 | z-index: 1;
75 |
76 | &::after {
77 | content: '';
78 | width: 100%;
79 | height: 100%;
80 |
81 | border-radius: 0 8px 8px 0;
82 | background-color: ${({ status, theme }) => statusToColor(status, theme.color)};
83 | }
84 | `,
85 | };
86 |
87 | export default S;
88 |
--------------------------------------------------------------------------------
/client/src/components/SideBarDropdown/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Title: styled.p`
5 | margin: 5px;
6 |
7 | font: ${({ theme }) => theme.font.body_regular};
8 | `,
9 | };
10 |
11 | export default S;
12 |
--------------------------------------------------------------------------------
/client/src/components/SideBarEntry/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import S from './style';
3 |
4 | interface SideBarEntryProps {
5 | icon: string;
6 | name: string;
7 | highlight: boolean;
8 | }
9 |
10 | const SideBarEntry = ({ icon, name, highlight }: SideBarEntryProps) => {
11 | return (
12 |
13 |
14 |
15 |
16 | {name}
17 |
18 | );
19 | };
20 |
21 | export default SideBarEntry;
22 |
--------------------------------------------------------------------------------
/client/src/components/SideBarEntry/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Container: styled.div`
5 | display: flex;
6 | align-items: center;
7 |
8 | cursor: pointer;
9 | `,
10 | Icon: styled.div<{ highlight: boolean }>`
11 | & img {
12 | filter: ${({ highlight }) =>
13 | highlight
14 | ? 'invert(33%) sepia(26%) saturate(3652%) hue-rotate(196deg) brightness(99%) contrast(85%);'
15 | : ''};
16 | }
17 | `,
18 | Label: styled.span<{ highlight: boolean }>`
19 | margin-left: 8px;
20 |
21 | font: ${({ theme }) => theme.font.body_regular};
22 | color: ${({ highlight, theme }) => (highlight ? theme.color.blue400 : theme.color.gray300)};
23 | `,
24 | };
25 |
26 | export default S;
27 |
--------------------------------------------------------------------------------
/client/src/components/SignUpForm/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { NewUser } from '@/lib/api/user';
4 | import * as S from './style';
5 | import { Button } from '@/lib/design';
6 |
7 | const SignUpForm = ({
8 | checkOrganizationByName,
9 | newUser,
10 | setNewUser,
11 | }: {
12 | token: string;
13 | checkOrganizationByName: (e: React.FormEvent) => Promise;
14 | newUser: NewUser;
15 | setNewUser: React.Dispatch>;
16 | }) => {
17 | const setUser = (property: { [index: string]: string }) => {
18 | return setNewUser({ ...newUser, ...property });
19 | };
20 | return (
21 |
22 | setUser({ name: e.target.value })}
26 | >
27 | setUser({ job: e.target.value })}
31 | >
32 | setUser({ email: e.target.value })}
37 | >
38 | setUser({ password: e.target.value })}
43 | >
44 | setUser({ checkPassword: e.target.value })}
49 | >
50 | setUser({ organization: e.target.value })}
54 | >
55 |
58 |
59 | );
60 | };
61 |
62 | export default SignUpForm;
63 |
--------------------------------------------------------------------------------
/client/src/components/SignUpForm/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const InputBox = styled.input`
4 | width: 40%;
5 | padding: 10px 10px;
6 | margin: 15px 0px;
7 |
8 | border-radius: 8px;
9 | `;
10 | const FormBox = styled.form`
11 | width: 70%;
12 |
13 | display: flex;
14 | flex-direction: column;
15 | justify-content: space-around;
16 | align-items: center;
17 |
18 | padding: 10px 0px;
19 | background-color: ${({ theme }) => theme.color.gray100};
20 |
21 | border-radius: 8px;
22 | `;
23 |
24 | export { InputBox, FormBox };
25 |
--------------------------------------------------------------------------------
/client/src/components/TeamInviteBar/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, ChangeEvent, FormEvent } from 'react';
2 |
3 | import { Button } from '@/lib/design';
4 |
5 | import * as S from './style';
6 | import { sendEmail } from '@/lib/api/email';
7 | import { useRecoilValue } from 'recoil';
8 | import userAtom from '@/recoil/user';
9 | import { toast } from 'react-toastify';
10 | import { errorMessage } from '@/lib/common/message';
11 |
12 | const TeamInviteBar = ({ onCloseClick }: { onCloseClick: (e: React.MouseEvent) => void }) => {
13 | const [value, setValue] = useState('');
14 | const userState = useRecoilValue(userAtom);
15 |
16 | const onChange = (e: ChangeEvent) => {
17 | setValue(e.target.value);
18 | };
19 |
20 | const onSubmit = (e: FormEvent) => {
21 | e.preventDefault();
22 | if (value === '') {
23 | toast.error(errorMessage.NULL_EMAIL);
24 | return;
25 | }
26 | sendEmail(userState.organization as number, value);
27 | setValue('');
28 | };
29 |
30 | return (
31 |
32 |
38 |
39 |
42 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default TeamInviteBar;
51 |
--------------------------------------------------------------------------------
/client/src/components/TeamInviteBar/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const EmailInputBarContainer = styled.form`
4 | width: 400px;
5 |
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 |
10 | border-radius: 8px;
11 | `;
12 |
13 | const InputBox = styled.input`
14 | width: 400px;
15 | height: 42px;
16 |
17 | background: ${({ theme }) => theme.color.gray100};
18 | font: ${({ theme }) => theme.font.body_regular};
19 |
20 | margin: 40px 0px;
21 |
22 | &:focus {
23 | border: 1px solid ${({ theme }) => theme.color.gray500};
24 | border-radius: 8px;
25 | }
26 | `;
27 |
28 | const ButtonContainer = styled.div`
29 | width: 400px;
30 |
31 | display: flex;
32 | align-items: center;
33 | justify-content: space-between;
34 |
35 | & button {
36 | width: calc(50% - 16px);
37 |
38 | & + & {
39 | margin-left: 16px;
40 | }
41 | }
42 | `;
43 |
44 | export { EmailInputBarContainer, InputBox, ButtonContainer };
45 |
--------------------------------------------------------------------------------
/client/src/components/TeamItemViewer/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const ThreeDot = styled.img`
4 | padding: 0px 10px;
5 | `;
6 |
7 | const SearchBarContainer = styled.div`
8 | display: flex;
9 | flex-direction: row-reverse;
10 | margin: 10px 0px;
11 | `;
12 |
13 | export { ThreeDot, SearchBarContainer };
14 |
--------------------------------------------------------------------------------
/client/src/components/TeamManagementItem/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import * as S from './style';
3 | import * as avatar from '@/lib/common/avatar';
4 | import { ImageType } from '@/types/image';
5 |
6 | interface TeamManagementItemProps {
7 | imageURL: string;
8 | name: string;
9 | job: string;
10 | admin: boolean;
11 | children: ReactNode;
12 | }
13 |
14 | const TeamManagementItem = ({ imageURL, name, job, admin, children }: TeamManagementItemProps) => {
15 | return (
16 |
17 |
18 |
19 | {name}
20 | {job}
21 | {admin ? 관리자 : 팀원}
22 |
23 | {children}
24 |
25 | );
26 | };
27 |
28 | export default TeamManagementItem;
29 |
--------------------------------------------------------------------------------
/client/src/components/TeamManagementItem/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const ItemContainer = styled.div`
4 | display: flex;
5 | justify-content: space-between;
6 | align-items: center;
7 |
8 | height: 70px;
9 | margin: 15px 0px;
10 | padding: 0 60px;
11 |
12 | border-radius: 8px;
13 | background-color: ${({ theme }) => theme.color.white};
14 | `;
15 |
16 | const Avatar = styled.img`
17 | width: 64px;
18 | height: 64px;
19 |
20 | border-radius: 32px;
21 | `;
22 |
23 | const Text = styled.span`
24 | font: ${({ theme }) => theme.font.body_regular};
25 | `;
26 |
27 | const TextContainer = styled.div`
28 | width: 300px;
29 | display: flex;
30 | justify-content: space-between;
31 | aligns-content: center;
32 | `;
33 |
34 | export { ItemContainer, Avatar, Text, TextContainer };
35 |
--------------------------------------------------------------------------------
/client/src/components/TodoInputBar/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent, useRef, useState } from 'react';
2 | import * as S from './style';
3 | import Button from '@/lib/design/Button';
4 | import { useRecoilValue, useSetRecoilState } from 'recoil';
5 | import { createTodo } from '@/lib/api/todo';
6 | import userAtom, { allTasksSelector } from '@/recoil/user';
7 | import { checkObjectInput } from '@/lib/utils/bytes';
8 |
9 | const TodoInputBar = () => {
10 | const userState = useRecoilValue(userAtom);
11 | const setAllTasks = useSetRecoilState(allTasksSelector);
12 |
13 | const [buttonDisabled, buttonDisableHandler] = useState(true);
14 | const todoInput = useRef(null);
15 | const inputHandler = (e: ChangeEvent) => {
16 | const target = e.target as HTMLInputElement;
17 | if (target.value !== '') {
18 | buttonDisableHandler(false);
19 | } else {
20 | buttonDisableHandler(true);
21 | }
22 | };
23 | const submitHandler = async (event: React.FormEvent) => {
24 | event.preventDefault();
25 | if (typeof userState.id === 'number') {
26 | const target = todoInput.current as HTMLInputElement;
27 | if (!checkObjectInput({ text: target.value })) return;
28 | const todo = await createTodo(target.value, userState.id);
29 | if (todo) {
30 | setAllTasks((prev) => [todo, ...prev]);
31 | }
32 | target.value = '';
33 | buttonDisableHandler(true);
34 | }
35 | };
36 |
37 | return (
38 |
39 |
40 |
50 |
51 | );
52 | };
53 |
54 | export default TodoInputBar;
55 |
--------------------------------------------------------------------------------
/client/src/components/TodoInputBar/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const TodoInputBarContainer = styled.form`
4 | width: 753px;
5 | height: 80px;
6 |
7 | display: flex;
8 | flex-direction: row;
9 | justify-content: space-around;
10 | align-items: center;
11 | margin-bottom: 20px;
12 |
13 | border-radius: 8px;
14 |
15 | background-color: ${({ theme }) => theme.color.gray100};
16 | `;
17 |
18 | const InputBox = styled.input`
19 | width: 580px;
20 | height: 42px;
21 | margin-left: 20px;
22 |
23 | background: none;
24 | font: ${({ theme }) => theme.font.body_regular};
25 |
26 | &:focus {
27 | border: 1px solid ${({ theme }) => theme.color.gray500};
28 | border-radius: 8px;
29 | }
30 | `;
31 |
32 | export { TodoInputBarContainer, InputBox };
33 |
--------------------------------------------------------------------------------
/client/src/components/index.ts:
--------------------------------------------------------------------------------
1 | // Header
2 | export { default as Profile } from './Profile/index';
3 |
4 | // MainPage
5 | export { default as TodoInputBar } from './TodoInputBar/index';
6 | export { default as ListViewHeader } from './ListViewHeader/index';
7 | export { default as ListViewItem } from './ListViewItem/index';
8 |
9 | // SideBar
10 | export { default as SideBarDropDown } from './SideBarDropdown/index';
11 | export { default as SideBarEntry } from './SideBarEntry/index';
12 |
13 | // RoadMap
14 | export { default as EpicPlaceholer } from './EpicPlaceholder/index';
15 |
16 | // KanBan
17 | export { default as KanbanColumn } from './KanbanColumn/index';
18 | export { default as KanbanItem } from './KanbanColumn/KanbanItem/index';
19 | export { default as KanbanItemInput } from './KanbanColumn/KanbanItemInput/index';
20 | export { default as KanbanAddBtn } from './KanbanColumn/KanbanAddBtn/index';
21 | export { default as KanbanDeleteModal } from './KanbanColumn/KanbanDeleteModal/index';
22 | export { default as KanbanModal } from './KanbanModal/index';
23 |
24 | // Project
25 | export { default as ProjectCreateForm } from './ProjectCreateForm';
26 | export { default as ProjectCard } from './ProjectCard';
27 | export { default as ProjectModal } from './ProjectModal';
28 |
--------------------------------------------------------------------------------
/client/src/contexts/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { EpicProvider } from './epicContext';
3 | import { StoryProvider } from './storyContext';
4 | import { UserProvider } from './userContext';
5 | import SocketConnector from './socketContext';
6 |
7 | export default function ContextProvider({ children }: { children: React.ReactNode }) {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/contexts/socketContext.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { io, Socket } from 'socket.io-client';
3 |
4 | export type SocketContextType = {
5 | connection: Socket;
6 | };
7 |
8 | export const SocketContext = React.createContext({
9 | connection: io(`${process.env.SERVER_URL}`, {
10 | transports: ['websocket'],
11 | autoConnect: false,
12 | }),
13 | });
14 |
15 | function SocketConnector(props: { children: React.ReactNode }) {
16 | const socketContext = React.useContext(SocketContext);
17 |
18 | const value = React.useMemo(() => socketContext, [socketContext]);
19 | const connection = socketContext?.connection;
20 |
21 | React.useEffect(() => {
22 | if (!connection) return;
23 | connection.connect();
24 | return () => {
25 | connection.disconnect();
26 | };
27 | }, []);
28 |
29 | return {props.children};
30 | }
31 |
32 | export default SocketConnector;
33 |
--------------------------------------------------------------------------------
/client/src/contexts/storyContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, Dispatch, useReducer } from 'react';
2 | import producer from 'immer';
3 | import { StoryType } from '@/types/story';
4 |
5 | type StoryState = Array;
6 |
7 | type StoryAction =
8 | | { type: 'ADD_STORY'; story: StoryType }
9 | | { type: 'REMOVE_STORY'; id: number }
10 | | { type: 'UPDATE_STORY'; story: StoryType }
11 | | { type: 'LOAD_STORY'; stories: StoryType[] }
12 | | { type: 'DROP_STORY' };
13 |
14 | type StoryDispatch = Dispatch;
15 |
16 | const StoryStateContext = createContext(null);
17 | const StoryDispatchContext = createContext(null);
18 |
19 | // to-do 필요한 action이 있으면, 아래에 추가할 것
20 | // to-do immutable 방식을 더 생각해볼 것
21 | function reducer(state: StoryState, action: StoryAction): StoryState {
22 | switch (action.type) {
23 | case 'ADD_STORY':
24 | return producer(state, (draft) => {
25 | draft.push({ ...action.story, status: 'TODO' });
26 | });
27 | case 'REMOVE_STORY':
28 | return producer(state, (draft) => {
29 | return draft.filter((el) => el.id !== action.id);
30 | });
31 | case 'UPDATE_STORY':
32 | return producer(state, (draft) => {
33 | return draft.map((el) => {
34 | if (el.id !== action.story.id) return el;
35 | return {
36 | ...el,
37 | ...action.story,
38 | };
39 | });
40 | });
41 | case 'LOAD_STORY':
42 | return [...action.stories];
43 | case 'DROP_STORY':
44 | return [];
45 | default:
46 | throw new Error('unhandled action');
47 | }
48 | }
49 |
50 | export function StoryProvider({ children }: { children: React.ReactNode }) {
51 | const [state, dispatch] = useReducer(reducer, []);
52 | return (
53 |
54 | {children}
55 |
56 | );
57 | }
58 |
59 | export { StoryStateContext, StoryDispatchContext };
60 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import { ThemeProvider } from 'styled-components';
5 | import theme from './styles/theme';
6 |
7 | const rootElement = document.getElementById('root');
8 | ReactDOM.render(
9 |
10 |
11 | ,
12 | rootElement,
13 | );
14 |
--------------------------------------------------------------------------------
/client/src/layers/Backlog/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import BackLogItem from '@/components/BackLogItem';
3 | import { useRecoilValue } from 'recoil';
4 | import storyListAtom from '@/recoil/story';
5 | import * as S from './style';
6 |
7 | const Backlog = () => {
8 | const stories = useRecoilValue(storyListAtom);
9 | const filteredStories = stories.filter((el) => el.name !== '');
10 | return (
11 |
12 | 프로젝트 백로그
13 |
14 | {filteredStories.map((el) => (
15 |
16 | ))}
17 |
18 |
19 | );
20 | };
21 |
22 | export default Backlog;
23 |
--------------------------------------------------------------------------------
/client/src/layers/Backlog/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.section`
4 | width: 947px;
5 | min-height: 600px;
6 |
7 | margin-left: 26px;
8 | padding: 18px;
9 |
10 | background-color: ${({ theme }) => theme.color.gray100};
11 | border-radius: 8px;
12 | `;
13 |
14 | const ItemContainer = styled.div`
15 | width: 80%;
16 | margin-left: 10%;
17 | margin-top: 30px;
18 | `;
19 |
20 | const Title = styled.h3`
21 | height: 50px;
22 | font: ${({ theme }) => theme.font.bold_medium};
23 | `;
24 |
25 | export { ItemContainer, Container, Title };
26 |
--------------------------------------------------------------------------------
/client/src/layers/CoworkerStatus/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.div`
4 | width: 367px;
5 |
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 |
10 | padding-top: 25px;
11 | padding-bottom: 29px;
12 |
13 | border-radius: 8px;
14 | background-color: ${({ theme }) => theme.color.gray100};
15 | `;
16 |
17 | const Name = styled.p`
18 | margin-left: 32px;
19 |
20 | font: ${({ theme }) => theme.font.body_regular};
21 | `;
22 |
23 | const StatusContainer = styled.div`
24 | width: 277px;
25 | height: 65px;
26 |
27 | display: flex;
28 | flex-direction: row;
29 | align-items: center;
30 | `;
31 |
32 | const UsersContainer = styled.div`
33 | width: 310px;
34 | height: 650px;
35 |
36 | padding-top: 29px;
37 |
38 | border-radius: 8px;
39 | background: ${({ theme }) => theme.color.white};
40 | `;
41 |
42 | export { Container, Name, StatusContainer, UsersContainer };
43 |
--------------------------------------------------------------------------------
/client/src/layers/GroupManagement/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.section`
4 | width: 941px;
5 |
6 | flex-direction: column;
7 | justify-content: center;
8 |
9 | margin: 0 0 0 26px;
10 | padding: 18px;
11 |
12 | border-radius: 8px;
13 | background-color: ${({ theme }) => theme.color.gray100};
14 | `;
15 |
16 | const ItemListViewer = styled.div`
17 | display: flex;
18 | flex-direction: column;
19 | `;
20 |
21 | const ThreeDot = styled.img`
22 | padding: 0px 10px;
23 | `;
24 |
25 | const SearchBarContainer = styled.div`
26 | display: flex;
27 | flex-direction: row-reverse;
28 | margin: 10px 0px;
29 | `;
30 |
31 | const ModalText = styled.div`
32 | margin: 40px 0px;
33 | font: ${({ theme }) => theme.font.body_regular};
34 | `;
35 |
36 | export { Container, ItemListViewer, ThreeDot, SearchBarContainer, ModalText };
37 |
--------------------------------------------------------------------------------
/client/src/layers/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Profile from '@/components/Profile';
3 | import { PageIcon } from '@/lib/design/PageIcon';
4 | import { Styled } from '@/layers/Header/style';
5 | import Logo from '@/lib/design/Logo';
6 |
7 | const Header = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default React.memo(Header);
22 |
--------------------------------------------------------------------------------
/client/src/layers/Header/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Styled = {
4 | header: styled.header`
5 | display: flex;
6 | justify-content: space-between;
7 | align-items: center;
8 |
9 | max-width: 1140px;
10 | width: 100%;
11 |
12 | margin-top: 25px;
13 | `,
14 | iconList: styled.ul`
15 | display: flex;
16 | align-items: center;
17 | justify-content: space-evenly;
18 |
19 | width: 30%;
20 | `,
21 | };
22 |
--------------------------------------------------------------------------------
/client/src/layers/Kanban/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import Styled from '@/layers/Kanban/style';
3 | import KanbanColumn from '@/components/KanbanColumn';
4 | import KanbanModal from '@/components/KanbanColumn/KanbanDeleteModal';
5 | import { StatusType } from '@/types/story';
6 |
7 | const Kanban = () => {
8 | const dragRef = useRef(0);
9 | const dragOverRef = useRef(0);
10 | const dragCategory = useRef('TODO');
11 | const dragOverCateogry = useRef('TODO');
12 |
13 | return (
14 |
15 |
16 | 프로젝트 칸반보드
17 |
18 | {[
19 | { category: 'TODO', id: 0 },
20 | { category: 'IN_PROGRESS', id: 1 },
21 | { category: 'DONE', id: 2 },
22 | ].map((value) => (
23 |
31 | ))}
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default Kanban;
39 |
--------------------------------------------------------------------------------
/client/src/layers/Kanban/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | Container: styled.section`
5 | display: flex;
6 | flex-direction: column;
7 |
8 | width: 941px;
9 |
10 | margin: 0 0 0 26px;
11 | padding: 18px;
12 |
13 | border-radius: 8px;
14 | background-color: ${({ theme }) => theme.color.gray100};
15 | `,
16 |
17 | Title: styled.h3`
18 | height: 50px;
19 | font: ${({ theme }) => theme.font.bold_medium};
20 | `,
21 |
22 | ColumnContainer: styled.section`
23 | width: 100%;
24 | height: calc(100% - 50px);
25 |
26 | display: flex;
27 | justify-content: space-around;
28 |
29 | font: ${({ theme }) => theme.font.body_medium};
30 | `,
31 |
32 | Column: styled.div`
33 | width: 30%;
34 | height: 100%;
35 |
36 | display: flex;
37 | flex-direction: column;
38 |
39 | align-items: center;
40 | position: relative;
41 |
42 | border-radius: 8px;
43 | background-color: ${({ theme }) => theme.color.white};
44 |
45 | h4 {
46 | padding-top: 12px;
47 | font: ${({ theme }) => theme.font.bold_regular};
48 | }
49 |
50 | button {
51 | width: 250px;
52 | height: 50px;
53 |
54 | position: absolute;
55 | bottom: 15px;
56 |
57 | color: ${({ theme }) => theme.color.gray400};
58 | }
59 | `,
60 |
61 | KanBanItem: styled.article`
62 | width: 90%;
63 | height: 60px;
64 | border-radius: 8px;
65 | margin-top: 10px;
66 |
67 | background-color: ${({ theme }) => theme.color.gray100};
68 |
69 | input {
70 | background-color: ${({ theme }) => theme.color.gray100};
71 |
72 | margin-top: 15px;
73 | margin-left: 10px;
74 | padding: 5px;
75 | width: 90%;
76 | height: 30px;
77 |
78 | font: ${({ theme }) => theme.font.bold_extra_small};
79 | font-size: 14px;
80 | }
81 | `,
82 | };
83 |
84 | export default Styled;
85 |
--------------------------------------------------------------------------------
/client/src/layers/ListView/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | Container: styled.section`
5 | width: 753px;
6 | height: 680px;
7 |
8 | padding: 15px 30px;
9 |
10 | border-radius: 8px;
11 | background-color: ${({ theme }) => theme.color.gray100};
12 | `,
13 | ItemWrapper: styled.ul`
14 | height: 550px;
15 | margin-top: 90px;
16 |
17 | overflow-y: scroll;
18 | &::-webkit-scrollbar {
19 | display: none;
20 | }
21 | `,
22 | EmptyWrapper: styled.div`
23 | display: flex;
24 | align-items: center;
25 | justify-content: center;
26 |
27 | height: 200px;
28 | font: ${({ theme }) => theme.font.bold_large};
29 | color: ${({ theme }) => theme.color.gray300};
30 | `,
31 | };
32 |
33 | export default Styled;
34 |
--------------------------------------------------------------------------------
/client/src/layers/ProjectManagement/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Styled = {
4 | ProjectManagementWrapper: styled.section`
5 | margin-left: 20px;
6 | `,
7 | ProjectList: styled.ul`
8 | display: flex;
9 | flex-wrap: wrap;
10 |
11 | width: 950px;
12 | height: 500px;
13 | margin-top: 20px;
14 | padding: 10px 0;
15 |
16 | background-color: ${({ theme }) => theme.color.gray100};
17 | border-radius: 8px;
18 |
19 | overflow-y: scroll;
20 | &::-webkit-scrollbar {
21 | display: none;
22 | }
23 | `,
24 | };
25 |
26 | export default Styled;
27 |
--------------------------------------------------------------------------------
/client/src/layers/Roadmap/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Container: styled.div`
5 | display: flex;
6 | flex-direction: column;
7 |
8 | width: 941px;
9 | min-height: calc(75vh);
10 |
11 | margin: 0 0 64px 26px;
12 | padding: 18px;
13 |
14 | border-radius: 8px;
15 | background-color: ${({ theme }) => theme.color.gray100};
16 |
17 | font: ${({ theme }) => theme.font.body_regular};
18 | color: ${({ theme }) => theme.color.gray400};
19 | `,
20 | TitleWrapper: styled.div`
21 | display: flex;
22 | justify-content: space-between;
23 | `,
24 | Title: styled.h2`
25 | margin-bottom: 16px;
26 |
27 | font: ${({ theme }) => theme.font.bold_medium};
28 | `,
29 | Content: styled.section`
30 | display: flex;
31 |
32 | width: 100%;
33 | height: 100%;
34 |
35 | background-color: ${({ theme }) => theme.color.white};
36 | border-radius: 8px;
37 | `,
38 | EpicEntry: styled.ul`
39 | position: relative;
40 | display: flex;
41 | flex-direction: column;
42 |
43 | width: 250px;
44 | padding: 64px 0 32px 0;
45 |
46 | border-right: 3px solid ${({ theme }) => theme.color.gray100};
47 |
48 | overflow: scroll;
49 |
50 | & button {
51 | position: absolute;
52 | bottom: 8px;
53 |
54 | width: 100%;
55 | }
56 |
57 | &::-webkit-scrollbar {
58 | display: none;
59 | }
60 | `,
61 | EpicEntrySpacer: styled.li<{ activated: boolean }>`
62 | margin: 5px 0;
63 | padding: 20px 0;
64 |
65 | font: ${({ theme }) => theme.font.body_regular};
66 | color: ${({ theme }) => theme.color.gray400};
67 | border-top: ${({ theme, activated }) =>
68 | activated ? `4px solid ${theme.color.blue200}` : `4px solid transparent`};
69 | white-space: nowrap;
70 | `,
71 | };
72 |
73 | export default S;
74 |
--------------------------------------------------------------------------------
/client/src/layers/SideBar/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import S from '@/layers/SideBar/style';
3 | import SideBarDropDown from '@/components/SideBarDropdown';
4 |
5 | interface SideBarProps {
6 | entries: React.ReactNode[];
7 | needDropDown: boolean;
8 | changeTab: (tabIndex: number) => void;
9 | }
10 |
11 | /**
12 | *
13 | * @param entries 사이드바에 항목으로 들어갈 리액트 컴포넌트들의 배열
14 | * @param changeTab 현재 보고있는 화면을 로드맵, 칸반보드, 백로그 페이지에서 바꿔줄 함수
15 | * @returns
16 | */
17 | const SideBar = ({ entries, changeTab, needDropDown }: SideBarProps) => {
18 | return (
19 |
20 | {needDropDown ? : undefined}
21 |
22 | {entries.map((entry, i) => (
23 | changeTab(i)}>
24 | {entry}
25 |
26 | ))}
27 |
28 |
29 | );
30 | };
31 |
32 | export default SideBar;
33 |
--------------------------------------------------------------------------------
/client/src/layers/SideBar/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const S = {
4 | Container: styled.section`
5 | position: sticky;
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 |
10 | top: 10px;
11 | width: 173px;
12 | height: 75vh;
13 | padding: 24px 16px;
14 |
15 | background-color: ${({ theme }) => theme.color.gray100};
16 | border-radius: 8px;
17 |
18 | font: ${({ theme }) => theme.font.body_regular};
19 | `,
20 | Entry: styled.ul`
21 | margin-top: 32px;
22 | `,
23 | EntryItem: styled.li`
24 | padding: 12px 0;
25 |
26 | color: ${({ theme }) => theme.color.gray300};
27 | `,
28 | };
29 |
30 | export default S;
31 |
--------------------------------------------------------------------------------
/client/src/layers/index.ts:
--------------------------------------------------------------------------------
1 | // MainPage
2 | export { default as Header } from './Header/index';
3 | export { default as SideBar } from './SideBar/index';
4 | export { default as ListView } from './ListView/index';
5 |
6 | // WorkPage
7 | export { default as Roadmap } from './Roadmap/index';
8 | export { default as Kanban } from './Kanban/index';
9 | export { default as Backlog } from './Backlog/index';
10 |
--------------------------------------------------------------------------------
/client/src/lib/api/email.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError } from 'axios';
2 | import { toast } from 'react-toastify';
3 | import { errorMessage, successMessage } from '../common/message';
4 |
5 | const instance = axios.create({
6 | baseURL: process.env.SERVER_URL + '/api/email',
7 | withCredentials: true,
8 | });
9 |
10 | export const sendEmail = async (organizationId: number, email: string) => {
11 | try {
12 | const result = await instance.post('', { email, organizationId });
13 | if (result.status === 201) toast.success(successMessage.SEND_EMAIL);
14 | } catch (e) {
15 | if ((e as AxiosError).response?.status === 409) {
16 | toast.error(errorMessage.SEND_EMAIL_SAME);
17 | } else {
18 | toast.error(errorMessage.SEND_EMAIL);
19 | }
20 | }
21 | };
22 |
--------------------------------------------------------------------------------
/client/src/lib/api/organization.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { toast } from 'react-toastify';
3 | import { errorMessage } from '@/lib/common/message';
4 |
5 | const instance = axios.create({
6 | baseURL: process.env.SERVER_URL + '/api/organizations',
7 | withCredentials: true,
8 | });
9 |
10 | export const searchOrganizationByName = async (name: string) => {
11 | try {
12 | const result = await instance.get(`?name=${name}`);
13 | return result.status;
14 | } catch {
15 | toast.error(errorMessage.GET_USER);
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/lib/api/project.ts:
--------------------------------------------------------------------------------
1 | import { ProjectType } from '@/types/project';
2 | import axios from 'axios';
3 | import { toast } from 'react-toastify';
4 | import { errorMessage } from '../common/message';
5 |
6 | const instance = axios.create({
7 | baseURL: process.env.SERVER_URL + '/api/projects',
8 | withCredentials: true,
9 | });
10 |
11 | interface Project {
12 | name: string;
13 | id: number;
14 | }
15 |
16 | export const getAllProjectsByUser = async (
17 | userId: number,
18 | organizationId: number,
19 | ): Promise => {
20 | try {
21 | const result = await instance.get(`/?userId=${userId}&organizationId=${organizationId}`);
22 | if (result.status % 400 < 100) throw new Error();
23 | return result.data;
24 | } catch (e) {
25 | toast.error(errorMessage.GET_PROJECT);
26 | }
27 | };
28 |
29 | export const createProject = async (name: string, userId: number) => {
30 | try {
31 | const newProject: { data: Project; status: number } = await instance.post('/', {
32 | name,
33 | userId,
34 | });
35 | if (newProject.status % 400 < 100) throw new Error();
36 | return newProject.data;
37 | } catch (error) {
38 | toast.error(errorMessage.CREATE_PROJECT);
39 | }
40 | };
41 |
42 | export const getAllProjectsByOrg = async (orgId: number) => {
43 | try {
44 | const projects: { data: Project[]; status: number } = await instance.get(`/${orgId}`);
45 | if (projects.status % 400 < 100) throw new Error();
46 | return projects.data;
47 | } catch (error) {
48 | toast.error(errorMessage.GET_PROJECT);
49 | }
50 | };
51 |
52 | export const deleteProjectById = async (projectId: number): Promise => {
53 | try {
54 | const result = await instance.delete(`/${projectId}`);
55 | if (result.status % 400 < 100) throw new Error();
56 | return result.statusText;
57 | } catch (error) {
58 | toast.error(errorMessage.DELETE_PROJECT);
59 | }
60 | };
61 |
--------------------------------------------------------------------------------
/client/src/lib/api/task.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { toast } from 'react-toastify';
3 | import { errorMessage } from '@/lib/common/message';
4 | import { BackLogTaskProps } from '@/types/task';
5 |
6 | const instance = axios.create({
7 | baseURL: process.env.SERVER_URL + '/api/tasks',
8 | withCredentials: true,
9 | });
10 |
11 | export const getTasksByStoryId = async (storyId: number) => {
12 | try {
13 | const result: { data: Array } = await instance.get(`/${storyId}`);
14 | return result.data;
15 | } catch (e) {
16 | toast.error(errorMessage.GET_TASK);
17 | }
18 | };
19 |
20 | export const updateTask = async (
21 | id: number,
22 | name: string,
23 | status: boolean,
24 | userId?: number,
25 | projectId?: number,
26 | ) => {
27 | try {
28 | const result = await instance.patch('', {
29 | id,
30 | name,
31 | status,
32 | userId,
33 | projectId,
34 | });
35 | if (result.status >= 400) throw Error();
36 | } catch (e) {
37 | toast.error(errorMessage.UPDATE_TASK);
38 | }
39 | };
40 |
41 | export const deleteTask = async (id: number) => {
42 | try {
43 | const result = await instance.delete(`?id=${id}`);
44 | if (result.status >= 400) throw Error();
45 | } catch (e) {
46 | toast.error(errorMessage.DELETE_TASK);
47 | }
48 | };
49 |
50 | export const postTask = async ({
51 | name,
52 | status,
53 | storyId,
54 | userId,
55 | projectId,
56 | }: {
57 | name: string;
58 | status: number;
59 | storyId: number;
60 | userId: null | number;
61 | projectId: null | number;
62 | }) => {
63 | try {
64 | const result = await instance.post('', {
65 | name,
66 | status,
67 | userId,
68 | projectId,
69 | storyId,
70 | });
71 | return result.data.id;
72 | } catch (e) {
73 | toast.error(errorMessage.CREATE_TASK);
74 | }
75 | };
76 |
--------------------------------------------------------------------------------
/client/src/lib/api/todo.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { toast } from 'react-toastify';
3 | import { errorMessage } from '@/lib/common/message';
4 |
5 | const instance = axios.create({
6 | baseURL: process.env.SERVER_URL + '/api/todo',
7 | withCredentials: true,
8 | });
9 |
10 | interface Todo {
11 | id: number;
12 | status: boolean;
13 | name: string;
14 | createdAt: string;
15 | updatedAt: string;
16 | }
17 |
18 | export const createTodo = async (name: string, userId: number) => {
19 | try {
20 | const result: { data: Todo } = await instance.post('', {
21 | name: name,
22 | userId: userId,
23 | });
24 | return result.data;
25 | } catch (e) {
26 | toast.error(errorMessage.CREATE_TODO);
27 | throw e;
28 | }
29 | };
30 |
31 | export const updateTodo = async (id: number, name: string, status: boolean) => {
32 | try {
33 | const result = await instance.patch('', {
34 | id,
35 | name,
36 | status,
37 | });
38 | if (result.status >= 400) throw Error();
39 | } catch (e) {
40 | toast.error(errorMessage.UPDATE_TODO);
41 | }
42 | };
43 |
44 | export const deleteTodo = async (id: number) => {
45 | try {
46 | const result = await instance.delete(`?id=${id}`);
47 | if (result.status >= 400) throw Error();
48 | } catch (e) {
49 | toast.error(errorMessage.DELETE_TODO);
50 | }
51 | };
52 |
--------------------------------------------------------------------------------
/client/src/lib/common/avatar/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Boy0 } from '@public/image/Boy-0.svg';
2 | export { default as Boy1 } from '@public/image/Boy-1.svg';
3 | export { default as Boy2 } from '@public/image/Boy-2.svg';
4 | export { default as Boy3 } from '@public/image/Boy-3.svg';
5 | export { default as Boy4 } from '@public/image/Boy-4.svg';
6 | export { default as Boy5 } from '@public/image/Boy-5.svg';
7 | export { default as Boy6 } from '@public/image/Boy-6.svg';
8 | export { default as Boy7 } from '@public/image/Boy-7.svg';
9 | export { default as Boy8 } from '@public/image/Boy-8.svg';
10 | export { default as Boy9 } from '@public/image/Boy-9.svg';
11 | export { default as Boy10 } from '@public/image/Boy-10.svg';
12 | export { default as Boy11 } from '@public/image/Boy-11.svg';
13 | export { default as Boy12 } from '@public/image/Boy-12.svg';
14 | export { default as Boy13 } from '@public/image/Boy-13.svg';
15 | export { default as Boy14 } from '@public/image/Boy-14.svg';
16 | export { default as Boy15 } from '@public/image/Boy-15.svg';
17 | export { default as Boy16 } from '@public/image/Boy-16.svg';
18 |
19 | export { default as Girl0 } from '@public/image/Girl-0.svg';
20 | export { default as Girl1 } from '@public/image/Girl-1.svg';
21 | export { default as Girl2 } from '@public/image/Girl-2.svg';
22 | export { default as Girl3 } from '@public/image/Girl-3.svg';
23 | export { default as Girl4 } from '@public/image/Girl-4.svg';
24 | export { default as Girl5 } from '@public/image/Girl-5.svg';
25 | export { default as Girl6 } from '@public/image/Girl-6.svg';
26 | export { default as Girl7 } from '@public/image/Girl-7.svg';
27 | export { default as Girl8 } from '@public/image/Girl-8.svg';
28 | export { default as Girl9 } from '@public/image/Girl-9.svg';
29 | export { default as Girl10 } from '@public/image/Girl-10.svg';
30 | export { default as Girl11 } from '@public/image/Girl-11.svg';
31 | export { default as Girl12 } from '@public/image/Girl-12.svg';
32 | export { default as Girl13 } from '@public/image/Girl-13.svg';
33 | export { default as Girl14 } from '@public/image/Girl-14.svg';
34 | export { default as Girl15 } from '@public/image/Girl-15.svg';
35 | export { default as Girl16 } from '@public/image/Girl-16.svg';
36 |
--------------------------------------------------------------------------------
/client/src/lib/common/link/Link.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LinkType } from '@/types/link';
3 | import { NavLink } from 'react-router-dom';
4 |
5 | const Link = ({ children, className, to }: LinkType) => {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | };
12 |
13 | export default Link;
14 |
--------------------------------------------------------------------------------
/client/src/lib/common/message/index.ts:
--------------------------------------------------------------------------------
1 | export { default as errorMessage } from './error';
2 | export { default as successMessage } from './success';
3 | export { default as warningMessage } from './warning';
4 |
--------------------------------------------------------------------------------
/client/src/lib/common/message/success.ts:
--------------------------------------------------------------------------------
1 | export const CREATE_EPIC = '에픽 생성에 성공했습니다!';
2 | export const SEND_EMAIL = '이메일 발송을 성공했습니다!';
3 | export const UPDATE_TASK = '프로젝트 테스크 리스트에 변경이 있습니다!';
4 |
5 | export default {
6 | CREATE_EPIC,
7 | SEND_EMAIL,
8 | UPDATE_TASK,
9 | };
10 |
--------------------------------------------------------------------------------
/client/src/lib/common/message/warning.ts:
--------------------------------------------------------------------------------
1 | export const ADMIN_ACCESS = '관리자 권한이 필요합니다.';
2 | export const MOVE_PROJECT = '현재 속한 프로젝트로만 이동이 가능합니다.';
3 | export const DELETE_MYSELF = '자신의 상태는 변경하실 수 없습니다.';
4 | export const CREATE_STORY_WITHOUT_AUTH = '프로젝티 관리 탭에서 프로젝트를 생성해주세요.';
5 |
6 | export default {
7 | ADMIN_ACCESS,
8 | MOVE_PROJECT,
9 | DELETE_MYSELF,
10 | CREATE_STORY_WITHOUT_AUTH,
11 | };
12 |
--------------------------------------------------------------------------------
/client/src/lib/design/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | type Category = 'default' | 'confirm' | 'cancel';
5 | type Size = 'small' | 'large';
6 | interface Props {
7 | category: Category;
8 | size: Size;
9 | children: string;
10 | onClick?: (event: React.MouseEvent) => void;
11 | disabled?: boolean;
12 | type?: 'button' | 'submit' | 'reset' | undefined;
13 | }
14 | interface LayoutProps {
15 | category: Category;
16 | size: Size;
17 | }
18 |
19 | const StyledButton = styled.button`
20 | margin: 0 auto;
21 | border-radius: 8px;
22 |
23 | padding: ${(props) => (props.size === 'large' ? '13px 50px' : '10px 15px')};
24 | font: ${(props) =>
25 | props.size === 'large' ? props.theme.font.bold_regular : props.theme.font.bold_extra_small};
26 |
27 | background-color: ${(props) =>
28 | props.category === 'default'
29 | ? props.theme.color.blue400
30 | : props.category === 'confirm'
31 | ? props.theme.color.red400
32 | : props.theme.color.gray100};
33 |
34 | color: ${(props) =>
35 | props.category === 'cancel' ? props.theme.color.gray300 : props.theme.color.gray100};
36 |
37 | &:hover {
38 | background-color: ${(props) => (props.category === 'default' ? props.theme.color.blue500 : '')};
39 | }
40 | &:active {
41 | background-color: ${(props) => (props.category === 'default' ? props.theme.color.gray100 : '')};
42 | color: ${(props) => (props.category === 'default' ? props.theme.color.blue500 : '')};
43 | }
44 | `;
45 |
46 | /**
47 | * Button Component를 반환하는 함수
48 | * @param props size, category, onClick, disabled(optional)
49 | * size 는 Size , category 는 Category
50 | * children 은 버튼에 전달될 text, onClick은 부모로부터 전달받을 이벤트 리스너
51 | * disabled 는
52 | * @returns Button Component
53 | */
54 | const Button = ({ category, size, children, onClick, disabled, type = 'submit' }: Props) => {
55 | return (
56 |
57 | {children}
58 |
59 | );
60 | };
61 |
62 | export default Button;
63 |
--------------------------------------------------------------------------------
/client/src/lib/design/Logo/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import logo from '@public/hyupup-logo.svg';
3 | import Link from '@/lib/common/link/Link';
4 |
5 | const Logo = styled(Link)`
6 | display: block;
7 | width: 165px;
8 | height: 70px;
9 | background-image: url(${logo});
10 | background-position: center center;
11 | background-size: contain;
12 | background-repeat: no-repeat;
13 | `;
14 | export default Logo;
15 |
--------------------------------------------------------------------------------
/client/src/lib/design/PageIcon/index.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import home from '@public/icons/home-icon.svg';
3 | import work from '@public/icons/work-icon.svg';
4 | import setting from '@public/icons/setting-icon.svg';
5 | import homeActive from '@public/icons/home-active-icon.svg';
6 | import workActive from '@public/icons/work-active-icon.svg';
7 | import settingActive from '@public/icons/setting-active-icon.svg';
8 | import Link from '@/lib/common/link/Link';
9 |
10 | interface PageProps {
11 | name: 'home' | 'work' | 'setting';
12 | }
13 |
14 | interface PageName {
15 | [key: string]: string;
16 | }
17 |
18 | const pageName: PageName = {
19 | home,
20 | work,
21 | setting,
22 | homeActive,
23 | workActive,
24 | settingActive,
25 | };
26 |
27 | /**
28 | * @property {string} name - 'home' | 'work' | 'setting'
29 | */
30 | export const PageIcon = styled(Link)`
31 | display: inline-block;
32 |
33 | width: 55px;
34 | height: 55px;
35 |
36 | border-radius: 8px;
37 | background-image: ${(props) => `url(${pageName[props.name]})`};
38 | background-repeat: no-repeat;
39 | background-position: center;
40 |
41 | &:hover {
42 | background-color: ${({ theme }) => theme.color.gray100};
43 | }
44 |
45 | &:active,
46 | &.active {
47 | background-color: ${({ theme }) => theme.color.blue100};
48 | background-image: ${(props) => `url(${pageName[props.name + 'Active']})`};
49 | }
50 | `;
51 |
--------------------------------------------------------------------------------
/client/src/lib/design/SearchBar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Styled from '@/lib/design/SearchBar/style';
3 |
4 | interface Props {
5 | color?: 'gray' | 'white';
6 | size?: 'large' | 'midium' | 'small';
7 | value: string;
8 | placeholder?: string;
9 | onChange: (e: React.ChangeEvent) => void;
10 | onSubmit?: (e: React.FormEvent) => void;
11 | }
12 |
13 | const SearchBar = ({ color, value, size, onChange, onSubmit, placeholder = '' }: Props) => {
14 | return (
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default SearchBar;
23 |
--------------------------------------------------------------------------------
/client/src/lib/design/SearchBar/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import searchIcon from '@public/icons/search-icon.svg';
3 |
4 | const Styled = {
5 | Form: styled.form<{ inputSize?: string }>`
6 | display: flex;
7 | align-items: center;
8 |
9 | width: ${({ inputSize }) =>
10 | inputSize === 'large' ? '800px' : inputSize === 'small' ? '100px' : '350px'};
11 | height: 45px;
12 | padding: 5px 15px;
13 |
14 | background-color: ${({ color, theme }) =>
15 | color === 'gray' ? theme.color.gray100 : theme.color.white};
16 | border-radius: 25px;
17 | `,
18 | Input: styled.input`
19 | width: 100%;
20 | background: none;
21 |
22 | font: ${({ theme }) => theme.font.body_small};
23 | `,
24 | Button: styled.button`
25 | width: 25px;
26 | height: 25px;
27 | background-image: url(${searchIcon});
28 |
29 | cursor: pointer;
30 | `,
31 | };
32 |
33 | export default Styled;
34 |
--------------------------------------------------------------------------------
/client/src/lib/design/Spinner/Bars/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import S from './style';
3 |
4 | const COUNT = 6;
5 |
6 | export interface BarsSpinnerProps {
7 | width?: number;
8 | height?: number;
9 | duration?: number;
10 | }
11 |
12 | const BarsSpinner = ({ width = 70, height = 70, duration }: BarsSpinnerProps) => {
13 | return (
14 |
15 |
16 | {[...Array(COUNT)].map((_, i) => (
17 |
18 | ))}
19 |
20 |
21 | );
22 | };
23 |
24 | export default BarsSpinner;
25 |
--------------------------------------------------------------------------------
/client/src/lib/design/Spinner/Bars/style.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const anim = keyframes`
4 | 0% {
5 | height: 100%;
6 | }
7 | 20% {
8 | height: 10%;
9 | }
10 | 80% {
11 | height: 10%;
12 | }
13 | 100% {
14 | height: 100%;
15 | }
16 | `;
17 |
18 | const S = {
19 | Container: styled.div`
20 | display: flex;
21 | justify-content: center;
22 | align-items: center;
23 |
24 | // FIXME: 수치를 어떻게 정해야하나?
25 | width: 100%;
26 | height: 100%;
27 | `,
28 | BarsWrapper: styled.div<{ width: number; height: number }>`
29 | width: ${({ width }) => width}px;
30 | height: ${({ height }) => height}px;
31 |
32 | display: flex;
33 | align-items: center;
34 | `,
35 | Line: styled.div<{ width: number; count: number; duration: number }>`
36 | width: ${({ width, count }) => width / (count * 2)}px;
37 |
38 | background-color: ${({ theme }) => theme.color.blue200};
39 | animation: ${anim} ${(props) => props.duration}s linear infinite;
40 |
41 | & + & {
42 | margin-left: 5px;
43 | }
44 |
45 | ${({ count, duration }) =>
46 | [...Array(count)]
47 | .map(
48 | (_, i) =>
49 | `&:nth-child(${i + 1}) {
50 | animation-delay: ${(duration / (count * 2)) * (i + 1)}s;
51 | }`,
52 | )
53 | .join('\n\n')}
54 | `,
55 | };
56 |
57 | export default S;
58 |
--------------------------------------------------------------------------------
/client/src/lib/design/Spinner/Circle/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import S from './style';
3 |
4 | export interface CircleSpinnerProps {
5 | radius: number;
6 | duration?: number;
7 | }
8 |
9 | const CircleSpinner = ({ radius, duration }: CircleSpinnerProps) => {
10 | return (
11 |
12 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default CircleSpinner;
26 |
--------------------------------------------------------------------------------
/client/src/lib/design/Spinner/Circle/style.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const rotate = keyframes`
4 | from {
5 | transform: rotate(0deg);
6 | }
7 | to {
8 | transform: rotate(360deg);
9 | }
10 | `;
11 |
12 | const fill = (radius: number) => keyframes`
13 | from {
14 | stroke-dashoffset: 0;
15 | }
16 | to {
17 | stroke-dashoffset: ${Math.PI * radius * 4};
18 | }
19 | `;
20 |
21 | const S = {
22 | Container: styled.div`
23 | display: flex;
24 | justify-content: center;
25 | align-items: center;
26 |
27 | // FIXME: 수치를 어떻게 정해야하나?
28 | width: 100%;
29 | height: 100%;
30 | `,
31 | Circle: styled.svg<{ radius: number; duration: number }>`
32 | fill: none;
33 | animation: ${rotate} ${(props) => props.duration}s linear infinite;
34 |
35 | & circle {
36 | stroke: ${({ theme }) => theme.color.blue200};
37 | stroke-dasharray: ${({ radius }) => Math.PI * 2 * radius};
38 | animation: ${fill(25)} ${(props) => props.duration + 1}s linear infinite;
39 | }
40 | `,
41 | };
42 |
43 | export default S;
44 |
--------------------------------------------------------------------------------
/client/src/lib/design/Spinner/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Styled from '@/lib/design/Spinner/style';
3 |
4 | export type SpinnerProps = {
5 | widthLevel?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
6 | heightValue?: number;
7 | colorValue?: 'gray' | 'white' | 'blue' | 'red';
8 | radiusValue?: number;
9 | };
10 |
11 | const Spinner = ({
12 | widthLevel = 10,
13 | heightValue = 800,
14 | colorValue = 'gray',
15 | radiusValue = 30,
16 | }: SpinnerProps) => {
17 | return (
18 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default Spinner;
29 |
--------------------------------------------------------------------------------
/client/src/lib/design/Spinner/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { SpinnerProps } from '.';
3 |
4 | const getGridToPx = (grid: number) => 96.67 * grid - 20;
5 |
6 | const Styled = {
7 | SpinnerWrapper: styled.section`
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 |
12 | width: ${({ widthLevel = 10 }) => getGridToPx(widthLevel) + 'px'};
13 | height: ${({ heightValue }) => heightValue + 'px'};
14 |
15 | border-radius: 8px;
16 | background-color: ${({ theme, colorValue }) => {
17 | switch (colorValue) {
18 | case 'white':
19 | return theme.color.white;
20 | case 'blue':
21 | return theme.color.blue100;
22 | case 'red':
23 | return theme.color.red100;
24 | default:
25 | return theme.color.gray100;
26 | }
27 | }};
28 | `,
29 | Spinner: styled.div<{ colorValue: string; radiusValue: number }>`
30 | width: ${({ radiusValue }) => radiusValue * 2 + 'px'};
31 | height: ${({ radiusValue }) => radiusValue * 2 + 'px'};
32 |
33 | border-radius: 70px;
34 | border: 7px solid;
35 | border-color: ${({ theme, colorValue }) =>
36 | colorValue === 'gray' || colorValue === 'white' ? theme.color.blue100 : theme.color.white};
37 | border-top-color: ${({ theme, colorValue }) =>
38 | colorValue === 'red' ? theme.color.red200 : theme.color.blue200};
39 | animation: 1s spin infinite linear;
40 | @keyframes spin {
41 | to {
42 | transform: rotate(360deg);
43 | }
44 | }
45 | `,
46 | };
47 |
48 | export default Styled;
49 |
--------------------------------------------------------------------------------
/client/src/lib/design/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Button } from './Button/index';
2 | export { default as DropDown } from './DropDown/index';
3 | export { default as Modal } from './Modal/index';
4 | export { default as SearchBar } from './SearchBar';
5 | export { default as Spinner } from './Spinner';
6 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useInput } from './useInput';
2 | export { default as useOutSideClick } from './useOutSideClick';
3 | export { default as useSocketSend } from './useSocketSend';
4 | export { default as useSocketReceive } from './useSocketReceive';
5 | export { default as useTabs } from './useTabs';
6 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useContextHooks.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { EpicStateContext, EpicDispatchContext } from '@/contexts/epicContext';
4 | import { StoryStateContext, StoryDispatchContext } from '@/contexts/storyContext';
5 | import { UserContext } from '@/contexts/userContext';
6 |
7 | export function useEpicState() {
8 | const state = useContext(EpicStateContext);
9 | if (!state) throw new Error('Cannot find EpicProvider'); // 유효하지 않을땐 에러를 발생
10 | return state;
11 | }
12 |
13 | export function useEpicDispatch() {
14 | const dispatch = useContext(EpicDispatchContext);
15 | if (!dispatch) throw new Error('Cannot find EpicProvider'); // 유효하지 않을땐 에러를 발생
16 | return dispatch;
17 | }
18 |
19 | export function useStoryState() {
20 | const state = useContext(StoryStateContext);
21 | if (!state) throw new Error('Cannot find StoryProvider'); // 유효하지 않을땐 에러를 발생
22 | return state;
23 | }
24 |
25 | export function useStoryDispatch() {
26 | const dispatch = useContext(StoryDispatchContext);
27 | if (!dispatch) throw new Error('Cannot find StoryProvider'); // 유효하지 않을땐 에러를 발생
28 | return dispatch;
29 | }
30 |
31 | export const useUserState = () => {
32 | const { userState } = useContext(UserContext);
33 | if (!userState) throw new Error('Cannot find UserProvider');
34 | return userState;
35 | };
36 |
37 | export const useUserDispatch = () => {
38 | const { dispatch } = useContext(UserContext);
39 | if (!dispatch) throw new Error('Cannot find UserProvider');
40 | return dispatch;
41 | };
42 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useInput.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, ChangeEvent } from 'react';
2 |
3 | type UseInputFn = {
4 | (initialValue?: string): {
5 | key: number;
6 | value: string;
7 | onReset: () => void;
8 | onChange: (e: ChangeEvent) => void;
9 | };
10 | };
11 |
12 | const useInput: UseInputFn = (initValue = '') => {
13 | const [value, setValue] = useState(initValue);
14 | const [key, setKey] = useState(-1);
15 |
16 | const onReset = useCallback(() => {
17 | setValue(initValue);
18 | }, [initValue]);
19 |
20 | const onChange = useCallback((e) => {
21 | setValue(e.target.value);
22 | setKey(Number(e.target.dataset.key));
23 | }, []);
24 |
25 | return { key: Number(key), value, onChange, onReset };
26 | };
27 |
28 | export default useInput;
29 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useOutSideClick.tsx:
--------------------------------------------------------------------------------
1 | import { RefObject, useEffect } from 'react';
2 |
3 | const useOutSideClick = (
4 | ref: RefObject,
5 | eventHandler: () => void,
6 | ): void => {
7 | useEffect(() => {
8 | const outClickListener = (event: MouseEvent) => {
9 | const el = ref?.current;
10 |
11 | if (!el || el.contains(event.target as Node)) return;
12 | eventHandler();
13 | };
14 |
15 | document.addEventListener('mousedown', outClickListener);
16 | return () => {
17 | document.removeEventListener('mousedown', outClickListener);
18 | };
19 | }, [ref, eventHandler]);
20 | };
21 |
22 | export default useOutSideClick;
23 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useSocketReceive.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { SocketContext } from '@/contexts/socketContext';
3 |
4 | /**
5 | *
6 | * @param channel 데이터 수신시 핸들러를 등록할 수신할 소켓 채널이름
7 | * @param onReceive 데이터 수신시 실행되는 이벤트 핸들러 함수
8 | * @param dependency 이벤트 구독을 갱신할 때 dependency 로 전달될 변수의 배열
9 | * @returns null
10 | * @example
11 | * useSocketReceive('hello', (payload) => {
12 | * console.log(payload);
13 | * }); // 'hello' 채널에 이벤트 발생시 받은 payload를 콘솔에 출력함
14 | */
15 | function useSocketReceive(
16 | channel: string,
17 | onReceive: (...payload: any[]) => void,
18 | dependency: any[] = [],
19 | ) {
20 | const { connection } = React.useContext(SocketContext);
21 |
22 | // 해당 훅을 사용한 컴포넌트가 마운트되면 이벤트 핸들러를 등록, 언마운트되면 제거함
23 | React.useEffect(() => {
24 | connection.on(channel, onReceive);
25 | return () => {
26 | connection.off(channel, onReceive);
27 | };
28 | }, [...dependency, channel, onReceive, connection]);
29 | }
30 |
31 | export default useSocketReceive;
32 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useSocketSend.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { SocketContext } from '@/contexts/socketContext';
3 |
4 | /**
5 | *
6 | * @param channel 데이터를 전송할 소켓 채널이름
7 | * @returns 파라미터로 전달한 데이터를 channel로 전송해주는 함수
8 | * @example
9 | * const helloEmit = useSocketSend('hello');
10 | * ...
11 | * helloEmit('hello world!'); // hello 채널로 'hello world' 문자열을 전송
12 | */
13 | function useSocketSend(channel: string) {
14 | const { connection } = React.useContext(SocketContext);
15 |
16 | // emit(1, 2, 3) 과 emit([1, 2, 3]) 이 동일한 기능을 하기 위한 로직
17 | return React.useCallback(
18 | (...dataToEmit: any[]) => {
19 | connection.emit(channel, dataToEmit.length !== 1 ? dataToEmit : dataToEmit[0]);
20 | },
21 | [channel, connection],
22 | );
23 | }
24 |
25 | export default useSocketSend;
26 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useTabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | function useTabs(initialTabIndex: number, tabs: React.ReactNode[]) {
4 | const [currentIndex, setCurrentIndex] = React.useState(initialTabIndex);
5 | return {
6 | currentIndex,
7 | currentTab: tabs[currentIndex],
8 | changeTab: setCurrentIndex,
9 | };
10 | }
11 |
12 | export default useTabs;
13 |
--------------------------------------------------------------------------------
/client/src/lib/utils/drag.ts:
--------------------------------------------------------------------------------
1 | import { StatusType, dragRefObjectType, dragCategoryType } from '@/types/story';
2 |
3 | export const handleDragStart = (
4 | e: React.DragEvent,
5 | order: number,
6 | category: StatusType,
7 | dragRef: dragRefObjectType,
8 | dragCategory: dragCategoryType,
9 | ) => {
10 | dragRef.current = order;
11 | dragCategory.current = category;
12 | };
13 |
14 | export const handleDragEnter = (
15 | e: React.DragEvent,
16 | order: number,
17 | category: StatusType,
18 | dragOverRef: dragRefObjectType,
19 | dragOverCategory: dragCategoryType,
20 | ) => {
21 | dragOverRef.current = order;
22 | dragOverCategory.current = category;
23 | };
24 |
--------------------------------------------------------------------------------
/client/src/lib/utils/sort.ts:
--------------------------------------------------------------------------------
1 | import { EpicType } from '@/types/epic';
2 | import { PrivateTask, ProjectTask } from '@/types/task';
3 |
4 | export const taskSortByUpdate = (a: PrivateTask | ProjectTask, b: PrivateTask | ProjectTask) =>
5 | a.updatedAt < b.updatedAt ? 1 : -1;
6 |
7 | export const sortEpicsByOrder = (a: EpicType, b: EpicType) => a.order - b.order;
8 |
--------------------------------------------------------------------------------
/client/src/pages/AdminPage/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense, useEffect } from 'react';
2 | import { useRecoilValue, useSetRecoilState } from 'recoil';
3 | import styled from 'styled-components';
4 |
5 | import group from '@public/icons/group.svg';
6 | import file from '@public/icons/file-copy.svg';
7 |
8 | import { SideBarEntry } from '@/components';
9 | import { SideBar } from '@/layers';
10 | import { useTabs } from '@/lib/hooks';
11 | import userAtom, { userListAtom } from '@/recoil/user';
12 | import { getUsersInfoWithProject } from '@/lib/api/user';
13 | import { Spinner } from '@/lib/design';
14 |
15 | const GroupManagement = React.lazy(() => import('@/layers/GroupManagement'));
16 | const ProjectManagement = React.lazy(() => import('@/layers/ProjectManagement'));
17 |
18 | const AdminPage = () => {
19 | const userState = useRecoilValue(userAtom);
20 | const setUserListState = useSetRecoilState(userListAtom);
21 | const tabs = [
22 | }>
23 |
24 | ,
25 | }>
26 |
27 | ,
28 | ];
29 | const { currentIndex, currentTab, changeTab } = useTabs(0, tabs);
30 | const sideBarEntries = [
31 | ,
32 | ,
33 | ];
34 |
35 | useEffect(() => {
36 | (async () => {
37 | const result = await getUsersInfoWithProject(userState.organization as number);
38 | if (!result) return;
39 | setUserListState(result);
40 | })();
41 | }, [userState.organization, setUserListState]);
42 |
43 | return (
44 | <>
45 |
46 |
47 | {currentTab}
48 |
49 | >
50 | );
51 | };
52 |
53 | export default AdminPage;
54 |
55 | const S = {
56 | Container: styled.div`
57 | display: flex;
58 | margin-top: 60px;
59 | `,
60 | };
61 |
--------------------------------------------------------------------------------
/client/src/pages/LandingPage/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Button from '@/lib/design/Button';
3 | import { Styled } from './style';
4 | import Logo from '@/lib/design/Logo';
5 |
6 | const LandingPage = () => {
7 | return (
8 |
9 |
10 |
11 |
12 | 일 잘하는 사람들을 위한
13 | 가장 간편한 협업 툴
14 |
15 |
18 |
19 |
20 | 로그인은 개인 정보 보호 정책 및 서비스 약관에 동의하는 것을 의미하며,
21 |
22 | 서비스 이용을 위해 이메일과 이름, 프로필 이미지를 수집합니다.
23 |
24 |
25 | Designed by vectorjuice / Freepik
26 |
27 |
28 | );
29 | };
30 |
31 | export default LandingPage;
32 |
--------------------------------------------------------------------------------
/client/src/pages/LandingPage/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Styled = {
4 | Container: styled.div`
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: space-evenly;
8 | align-items: center;
9 |
10 | height: 100vh;
11 | `,
12 |
13 | Title: styled.p`
14 | margin: 5px;
15 |
16 | font: ${({ theme }) => theme.font.display_large};
17 | `,
18 |
19 | Body: styled.p`
20 | margin: 5px;
21 |
22 | color: ${({ theme }) => theme.color300};
23 |
24 | font: ${({ theme }) => theme.font.body_extra_small};
25 | `,
26 |
27 | TextContainer: styled.div`
28 | display: flex;
29 | flex-direction: column;
30 | justify-content: space-evenly;
31 | align-items: center;
32 | `,
33 | };
34 |
--------------------------------------------------------------------------------
/client/src/pages/LogInPage/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSetRecoilState } from 'recoil';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import Button from '@/lib/design/Button';
6 | import { Styled } from './style';
7 | import { LogInForm } from '@/components/LogInForm';
8 | import Logo from '@/lib/design/Logo';
9 | import { sudoLogIn } from '@/lib/api/user';
10 | import { UserState } from '@/contexts/userContext';
11 | import userAtom from '@/recoil/user';
12 |
13 | const LogInPage = () => {
14 | const navigate = useNavigate();
15 | const setUserState = useSetRecoilState(userAtom);
16 |
17 | const onClickSignIn = () => {
18 | navigate('/signup');
19 | };
20 |
21 | const onClickSudoSignIn = async () => {
22 | const userData = (await sudoLogIn()) as UserState;
23 | if (userData.projects && userData.projects?.length > 0) {
24 | userData.currentProjectId = userData.projects[0].id;
25 | userData.currentProjectName = userData.projects[0].name;
26 | }
27 | setUserState(userData);
28 | };
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
37 |
40 |
41 | TEST 계정으로 시작하기
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default LogInPage;
49 |
--------------------------------------------------------------------------------
/client/src/pages/LogInPage/style.ts:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 | import styled from 'styled-components';
3 |
4 | export const Styled = {
5 | Container: styled.div`
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 | height: 100vh;
10 | `,
11 |
12 | Logo: styled(NavLink)`
13 | font: ${({ theme }) => theme.font.display_medium};
14 | color: ${({ theme }) => theme.color.gray500};
15 | text-align: center;
16 | `,
17 |
18 | LogoContainer: styled.div`
19 | width: 100%;
20 | margin-top: 30px;
21 | `,
22 |
23 | ContentContainer: styled.div`
24 | width: 80%;
25 | height: 480px;
26 | margin-top: 200px;
27 |
28 | display: flex;
29 | flex-direction: column;
30 | justify-content: space-between;
31 | align-items: center;
32 |
33 | background-color: ${({ theme }) => theme.color.gray100};
34 | border-radius: 8px;
35 | padding: 60px;
36 | `,
37 |
38 | SudoLogin: styled.button`
39 | margin: 15px auto;
40 | border-radius: 8px;
41 | padding: 13px 50px;
42 | font: ${({ theme }) => theme.font.bold_small};
43 | background-color: ${({ theme }) => theme.color.blue400};
44 | color: ${({ theme }) => theme.color.white};
45 | `,
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import TodoInputBar from '@/components/TodoInputBar';
4 | import ListView from '@/layers/ListView';
5 | import CoworkerStatus from '@/layers/CoworkerStatus';
6 |
7 | const MainPage = () => {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | >
18 | );
19 | };
20 |
21 | export default MainPage;
22 |
23 | const ContentContainer = styled.div`
24 | margin-top: 60px;
25 | display: flex;
26 | flex-direction: row;
27 | justify-content: space-between;
28 | `;
29 |
--------------------------------------------------------------------------------
/client/src/pages/SignUpPage/style.ts:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled.div`
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: space-around;
8 | align-items: center;
9 |
10 | height: 95vh;
11 | `;
12 |
13 | const Title = styled(NavLink)`
14 | font: ${({ theme }) => theme.font.display_medium};
15 | color: ${({ theme }) => theme.color.gray500};
16 | text-align: center;
17 | `;
18 | const Text = styled.p`
19 | margin: 10px 20px;
20 | font: ${({ theme }) => theme.font.body_regular};
21 | `;
22 |
23 | export { Container, Title, Text };
24 |
--------------------------------------------------------------------------------
/client/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { default as LandingPage } from './LandingPage/index';
2 | export { default as MainPage } from './MainPage/index';
3 | export { default as WorkPage } from './WorkPage/index';
4 |
--------------------------------------------------------------------------------
/client/src/recoil/calendar/atom.ts:
--------------------------------------------------------------------------------
1 | import { addDate } from '@/lib/utils/date';
2 | import { atom } from 'recoil';
3 |
4 | export const WEEK_OFFSET = 14;
5 |
6 | const calendarAtom = atom<{ middle: Date; rangeFrom: Date; rangeTo: Date }>({
7 | key: 'atom/calendar',
8 | default: {
9 | middle: new Date(),
10 | rangeFrom: addDate(new Date(), (-1 * WEEK_OFFSET) / 2),
11 | rangeTo: addDate(new Date(), WEEK_OFFSET / 2),
12 | },
13 | });
14 |
15 | export default calendarAtom;
16 |
--------------------------------------------------------------------------------
/client/src/recoil/story/atom.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 | import { StoryListType } from '@/types/story';
3 |
4 | const storyListAtom = atom({
5 | key: 'storyListAtom',
6 | default: [],
7 | });
8 |
9 | export default storyListAtom;
10 |
--------------------------------------------------------------------------------
/client/src/recoil/story/index.ts:
--------------------------------------------------------------------------------
1 | import storyListAtom from '@/recoil/story/atom';
2 | import { storyState, tasksSelector } from '@/recoil/story/selector';
3 |
4 | export { storyState, tasksSelector };
5 | export default storyListAtom;
6 |
--------------------------------------------------------------------------------
/client/src/recoil/story/selector.ts:
--------------------------------------------------------------------------------
1 | import { DefaultValue, selectorFamily } from 'recoil';
2 | import produce from 'immer';
3 | import storyListAtom from './atom';
4 | import { StoryType } from '@/types/story';
5 | import { getTasksByStoryId } from '@/lib/api/task';
6 |
7 | export const storyState = selectorFamily({
8 | key: 'storyWithIdSelector',
9 | get:
10 | (id) =>
11 | ({ get }) => {
12 | const storyList = get(storyListAtom);
13 | return storyList?.find((story) => story.id === id);
14 | },
15 | set:
16 | (id: number) =>
17 | ({ set, get }, newValue) => {
18 | set(
19 | storyListAtom,
20 | newValue instanceof DefaultValue
21 | ? get(storyListAtom)
22 | : (prev) =>
23 | produce(prev, (draft) => {
24 | const newStoryIdx = draft?.findIndex((story) => story.id === id);
25 | if (newStoryIdx === -1 || !newValue) return;
26 | draft[newStoryIdx] = newValue;
27 | }),
28 | );
29 | },
30 | });
31 |
32 | export const tasksSelector = selectorFamily({
33 | key: 'tasksSelector',
34 | get: (storyId: number) => async () => {
35 | const taskList = await getTasksByStoryId(storyId);
36 | return taskList?.length ? taskList : [];
37 | },
38 | });
39 |
--------------------------------------------------------------------------------
/client/src/recoil/user/atom.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 | import { ProjectType } from '@/types/project';
3 | import { UserInfoWithProject } from '@/types/users';
4 | import { AllTask } from '@/types/task';
5 |
6 | export type UserState = {
7 | id?: number;
8 | name?: string;
9 | job?: string;
10 | email?: string;
11 | imageURL?: string;
12 | admin?: boolean;
13 | organization?: number;
14 | currentProjectName?: string;
15 | currentProjectId?: number;
16 | projects?: Array;
17 | allTasks?: Array;
18 | taskOffset?: number;
19 | };
20 |
21 | const userAtom = atom({
22 | key: 'userAtom',
23 | default: { taskOffset: 0 },
24 | });
25 |
26 | const userListAtom = atom({
27 | key: 'userListAtome',
28 | default: [],
29 | });
30 |
31 | export { userAtom, userListAtom };
32 |
--------------------------------------------------------------------------------
/client/src/recoil/user/index.ts:
--------------------------------------------------------------------------------
1 | import { userAtom, userListAtom } from '@/recoil/user/atom';
2 | import {
3 | privateTasksSelector,
4 | projectTasksSelector,
5 | allTasksSelector,
6 | taskOffsetSelector,
7 | } from '@/recoil/user/selector';
8 |
9 | export {
10 | userListAtom,
11 | privateTasksSelector,
12 | projectTasksSelector,
13 | allTasksSelector,
14 | taskOffsetSelector,
15 | };
16 |
17 | export default userAtom;
18 |
--------------------------------------------------------------------------------
/client/src/recoil/user/selector.ts:
--------------------------------------------------------------------------------
1 | import { DefaultValue, selector } from 'recoil';
2 | import produce from 'immer';
3 | import userAtom from '@/recoil/user';
4 | import { AllTask } from '@/types/task';
5 |
6 | export const privateTasksSelector = selector({
7 | key: 'privateTasksSelector',
8 | get: ({ get }) => {
9 | const user = get(userAtom);
10 | return user.allTasks ? user.allTasks.filter((task) => !task.projectId) : [];
11 | },
12 | });
13 |
14 | export const projectTasksSelector = selector({
15 | key: 'projectTasksSelector',
16 | get: ({ get }) => {
17 | const user = get(userAtom);
18 | return user.allTasks ? user.allTasks.filter((task) => task.projectId) : [];
19 | },
20 | });
21 |
22 | export const taskOffsetSelector = selector({
23 | key: 'taskOffsetSelector',
24 | get: ({ get }) => {
25 | const user = get(userAtom);
26 | return user.taskOffset ? user.taskOffset : 0;
27 | },
28 | set: ({ set }, newValue) => {
29 | set(
30 | userAtom,
31 | newValue instanceof DefaultValue
32 | ? newValue
33 | : (prev) =>
34 | produce(prev, (draft) => {
35 | draft.taskOffset = newValue;
36 | }),
37 | );
38 | },
39 | });
40 |
41 | export const allTasksSelector = selector({
42 | key: 'allTasksSelector',
43 | get: ({ get }) => {
44 | const user = get(userAtom);
45 | return user.allTasks ? user.allTasks : [];
46 | },
47 | set: ({ set }, newValue) => {
48 | set(
49 | userAtom,
50 | newValue instanceof DefaultValue
51 | ? newValue
52 | : (prev) =>
53 | produce(prev, (draft) => {
54 | draft.allTasks = [...newValue];
55 | }),
56 | );
57 | },
58 | });
59 |
--------------------------------------------------------------------------------
/client/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/client/src/stories/BarsSpinner.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Story } from '@storybook/react';
2 | import React from 'react';
3 | import BarsSpinner, { BarsSpinnerProps } from '@/lib/design/Spinner/Bars';
4 |
5 | export default {
6 | title: 'src/lib/design/Spinner/Bars',
7 | component: BarsSpinner,
8 | argTypes: {
9 | width: {
10 | control: {
11 | type: 'number',
12 | },
13 | defaultValue: 70,
14 | },
15 | height: {
16 | control: {
17 | type: 'number',
18 | },
19 | defaultValue: 70,
20 | },
21 | duration: {
22 | control: {
23 | type: 'number',
24 | },
25 | defaultValue: 1,
26 | },
27 | },
28 | } as Meta;
29 |
30 | const Template: Story = (args: BarsSpinnerProps) => ;
31 |
32 | export const Spinner = (args: BarsSpinnerProps) => ;
33 |
--------------------------------------------------------------------------------
/client/src/stories/Button.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Story } from '@storybook/react';
2 | import React from 'react';
3 | import Button from '@/lib/design/Button';
4 |
5 | type Category = 'default' | 'confirm' | 'cancel';
6 | type Size = 'small' | 'large';
7 | interface ButtonProps {
8 | category: Category;
9 | size: Size;
10 | children: string;
11 | onClick: (event: React.MouseEvent) => void;
12 | disabled?: boolean;
13 | }
14 |
15 | export default {
16 | title: 'src/lib/design/Button',
17 | component: Button,
18 | argTypes: {
19 | category: {
20 | control: {
21 | type: 'select',
22 | options: ['default', 'confirm', 'cancel'],
23 | },
24 | defaultValue: 'default',
25 | },
26 | size: {
27 | control: {
28 | type: 'select',
29 | options: ['small', 'large'],
30 | },
31 | defaultValue: 'large',
32 | },
33 | children: {
34 | control: 'text',
35 | defaultValue: 'Click',
36 | },
37 | onClick: {
38 | action: 'clicked',
39 | },
40 | },
41 | } as Meta;
42 |
43 | const Template: Story = (args: ButtonProps) => ;
44 |
45 | export const DefaultBtn = (args: ButtonProps) => ;
46 |
--------------------------------------------------------------------------------
/client/src/stories/CircleSpinner.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Story } from '@storybook/react';
2 | import React from 'react';
3 | import CircleSpinner, { CircleSpinnerProps } from '@/lib/design/Spinner/Circle';
4 |
5 | export default {
6 | title: 'src/lib/design/Spinner/Circle',
7 | component: CircleSpinner,
8 | argTypes: {
9 | radius: {
10 | control: {
11 | type: 'number',
12 | },
13 | defaultValue: 25,
14 | },
15 | duration: {
16 | control: {
17 | type: 'number',
18 | },
19 | defaultValue: 0.5,
20 | },
21 | },
22 | } as Meta;
23 |
24 | const Template: Story = (args: CircleSpinnerProps) => (
25 |
26 | );
27 |
28 | export const Spinner = (args: CircleSpinnerProps) => ;
29 |
--------------------------------------------------------------------------------
/client/src/stories/DropDown.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Story } from '@storybook/react';
2 | import React from 'react';
3 | import { DropDown } from '@/lib/design';
4 |
5 | interface IncludeId {
6 | id: number;
7 | name: string;
8 | }
9 |
10 | interface DropDownProps {
11 | Title: React.ReactNode;
12 | list: Array;
13 | font?: string;
14 | handleClick: (e: React.MouseEvent) => void;
15 | }
16 |
17 | export default {
18 | title: 'src/lib/design/DropDown',
19 | component: DropDown,
20 | argTypes: {
21 | Title: {
22 | control: 'text',
23 | defaultValue: 'dropdown',
24 | },
25 | list: {
26 | control: 'array',
27 | defaultValue: [{ id: 1, name: 'first' }],
28 | },
29 | handleClick: {
30 | action: 'clicked',
31 | },
32 | },
33 | } as Meta;
34 |
35 | const Template: Story = (args: DropDownProps) => ;
36 |
37 | export const DefalutDropDown = (args: DropDownProps) => ;
38 |
--------------------------------------------------------------------------------
/client/src/stories/SearchBar.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Story } from '@storybook/react';
2 | import { action } from '@storybook/addon-actions';
3 | import React from 'react';
4 | import SearchBar from '@/lib/design/SearchBar';
5 |
6 | interface Props {
7 | color?: 'gray' | 'white';
8 | size?: 'large' | 'midium' | 'small';
9 | value: string;
10 | onChange: (e: React.ChangeEvent) => void;
11 | onSubmit?: (e: React.FormEvent) => void;
12 | }
13 |
14 | export default {
15 | title: 'src/lib/design/SearchBar',
16 | component: SearchBar,
17 | } as Meta;
18 |
19 | const Template: Story = (args) => (
20 | {
23 | e.preventDefault();
24 | action('submitted')(e);
25 | }}
26 | />
27 | );
28 |
29 | export const DefaultSearchBar = (args: Props) => ;
30 |
--------------------------------------------------------------------------------
/client/src/stories/Spinner.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Story } from '@storybook/react';
2 | import React from 'react';
3 | import { Spinner } from '@/lib/design';
4 |
5 | type SpinnerProps = {
6 | widthLevel: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
7 | heightValue: number;
8 | colorValue: 'gray' | 'white' | 'blue' | 'red';
9 | };
10 |
11 | export default {
12 | title: 'src/lib/design/Spinner',
13 | component: Spinner,
14 | } as Meta;
15 |
16 | const Template: Story = (args) => ;
17 |
18 | export const DefaultSpinner = (args: SpinnerProps) => ;
19 |
--------------------------------------------------------------------------------
/client/src/styles/GlobalStyle.ts:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 | import reset from 'styled-reset';
3 | import PretendardBold from '../../public/fonts/Pretendard-Bold.woff2';
4 | import PretendardRegular from '../../public/fonts/Pretendard-Regular.woff2';
5 |
6 | const GlobalStyle = createGlobalStyle`
7 | ${reset}
8 |
9 | * {
10 | box-sizing: border-box;
11 | }
12 |
13 | html{
14 | font-size: 10px;
15 | }
16 | @media screen and (max-width: 1440px) {
17 | html {
18 | font-size: 8px;
19 | }
20 | }
21 | @media screen and (max-width: 1024px) {
22 | html {
23 | font-size: 6px;
24 | }
25 | }
26 |
27 | @font-face {
28 | font-family: 'Pretendard';
29 | src: local('PretendardRegular'),
30 | url(${PretendardRegular}) format('woff2');
31 | font-weight: 300;
32 | font-style: normal;
33 | font-display: swap;
34 | }
35 |
36 | @font-face {
37 | font-family: 'Pretendard';
38 | src: local('PretendardBold'),
39 | url(${PretendardBold}) format('woff2');
40 | font-weight: 600;
41 | font-style: normal;
42 | font-display: swap;
43 | }
44 |
45 | :root {
46 | --toastify-color-dark: #30333E;
47 | --toastify-color-info: #2D6ECF;
48 | --toastify-color-success: #5FCC7C;
49 | --toastify-color-error: #F06E69;
50 | font: normal 600 14px 'Pretendard';
51 | }
52 |
53 | body {
54 | -webkit-font-smoothing: antialiased;
55 | -moz-osx-font-smoothing: grayscale;
56 |
57 | font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
58 | padding: 0;
59 | max-height: 100vh;
60 | overflow: overlay;
61 | -ms-overflow-style: none;
62 | max-width: 1140px;
63 | margin: 0 auto;
64 |
65 | color: #30333E;
66 |
67 | ::-webkit-scrollbar {
68 | width: 6px;
69 | }
70 | ::-webkit-scrollbar-thumb {
71 | border-radius: 6px;
72 | background-color: rgba(223, 223, 223, 0.6);
73 |
74 | &:hover {
75 | background-color: rgba(193, 193, 193, 0.6);
76 | }
77 | }
78 |
79 | };
80 |
81 | a { color: #fff; text-decoration: none; outline: none }
82 | button{ outline: none; background: none; border: none; cursor: pointer; };
83 | input { border: none; outline: none; }
84 | `;
85 |
86 | export default GlobalStyle;
87 |
--------------------------------------------------------------------------------
/client/src/styles/theme.ts:
--------------------------------------------------------------------------------
1 | const color = {
2 | white: '#FFFFFF',
3 | gray100: '#FAFAFA',
4 | gray200: '#EFEFEF',
5 | gray300: '#8993A1',
6 | gray400: '#30333E',
7 | gray500: '#1C2229',
8 | blue100: '#E2EEFF',
9 | blue200: '#95BBF2',
10 | blue300: '#568EE2',
11 | blue400: '#2D6ECF',
12 | blue500: '#255FB5',
13 | red100: '#FFE7E7',
14 | red200: '#EFA8A5',
15 | red300: '#EE8B87',
16 | red400: '#F06E69',
17 | red500: '#F73B3B',
18 | green100: '#E0FFE8',
19 | green200: '#9DE9B1',
20 | green300: '#5FCC7C',
21 | green400: '#4FAAB67',
22 | green500: '#408E54',
23 | };
24 |
25 | const font = {
26 | body_extra_small: "normal 300 12px 'Pretendard'",
27 | body_small: "normal 300 14px 'Pretendard'",
28 | body_regular: "normal 300 16px 'Pretendard'",
29 | body_medium: "normal 300 18px 'Pretendard'",
30 | body_large: "normal 300 22px 'Pretendard'",
31 | bold_extra_small: "normal 600 12px 'Pretendard'",
32 | bold_small: "normal 600 14px 'Pretendard'",
33 | bold_regular: "normal 600 16px 'Pretendard'",
34 | bold_medium: "normal 600 18px 'Pretendard'",
35 | bold_large: "normal 600 22px 'Pretendard'",
36 | display_small: "normal 600 24px 'Pretendard'",
37 | display_medium: "normal 600 30px 'Pretendard'",
38 | display_large: "normal 600 48px 'Pretendard'",
39 | };
40 |
41 | const shadow = {
42 | default: `0px 4px 16px 4px rgba(0, 0, 0, 20%)`,
43 | };
44 |
45 | const theme = {
46 | color,
47 | font,
48 | shadow,
49 | };
50 |
51 | export default theme;
52 |
--------------------------------------------------------------------------------
/client/src/types/epic.ts:
--------------------------------------------------------------------------------
1 | export type EpicType = {
2 | id: number;
3 | projectId: number;
4 | name: string;
5 | startAt: Date;
6 | endAt: Date;
7 | order: number;
8 | };
9 |
10 | export type EpicWithString = {
11 | id: number;
12 | projectId: number;
13 | name: string;
14 | startAt: string;
15 | endAt: string;
16 | order: number;
17 | };
18 |
19 | export const isEpicType = (param: {
20 | id: unknown;
21 | projectId: number;
22 | name: unknown;
23 | startAt: unknown;
24 | endAt: unknown;
25 | order: unknown;
26 | }): param is EpicType => {
27 | return (
28 | typeof param.id === 'number' &&
29 | typeof param.projectId === 'number' &&
30 | typeof param.name === 'string' &&
31 | param.startAt instanceof Date &&
32 | param.endAt instanceof Date &&
33 | typeof param.order === 'number'
34 | );
35 | };
36 |
37 | export type CalendarRange = {
38 | rangeFrom: Date;
39 | rangeTo: Date;
40 | columns: number;
41 | };
42 |
43 | export type RoadmapBarsStatus = 'UNINITIALIZED' | 'NOT_STARTED' | 'STARTED' | 'ALL_DONE';
44 |
45 | export type EpicRenderInfo = {
46 | id: number;
47 | index: number;
48 | length: number;
49 | exceedsLeft: boolean;
50 | exceedsRight: boolean;
51 | status: RoadmapBarsStatus;
52 | };
53 |
--------------------------------------------------------------------------------
/client/src/types/image.d.ts:
--------------------------------------------------------------------------------
1 | export type ImageType =
2 | | 'Boy0'
3 | | 'Boy1'
4 | | 'Boy2'
5 | | 'Boy3'
6 | | 'Boy4'
7 | | 'Boy5'
8 | | 'Boy6'
9 | | 'Boy7'
10 | | 'Boy8'
11 | | 'Boy9'
12 | | 'Boy10'
13 | | 'Boy11'
14 | | 'Boy12'
15 | | 'Boy13'
16 | | 'Boy14'
17 | | 'Boy15'
18 | | 'Boy16'
19 | | 'Girl0'
20 | | 'Girl1'
21 | | 'Girl2'
22 | | 'Girl3'
23 | | 'Girl4'
24 | | 'Girl5'
25 | | 'Girl6'
26 | | 'Girl7'
27 | | 'Girl8'
28 | | 'Girl9'
29 | | 'Girl10'
30 | | 'Girl11'
31 | | 'Girl12'
32 | | 'Girl13'
33 | | 'Girl14'
34 | | 'Girl15'
35 | | 'Girl16';
36 |
--------------------------------------------------------------------------------
/client/src/types/link.d.ts:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | export interface LinkType {
4 | isActive?: boolean;
5 | children?: ReactNode;
6 | className?: string;
7 | to: string;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/types/png.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.png' {
2 | const value: string;
3 | export default value;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/types/project.d.ts:
--------------------------------------------------------------------------------
1 | export type ProjectType = {
2 | id: number;
3 | name: string;
4 | };
5 |
--------------------------------------------------------------------------------
/client/src/types/story.d.ts:
--------------------------------------------------------------------------------
1 | import { EpicType } from './epic';
2 |
3 | export type StoryListType = StoryType[];
4 |
5 | export type StatusType = 'TODO' | 'IN_PROGRESS' | 'DONE';
6 |
7 | export type StoryType = {
8 | name?: string;
9 | status?: StoryStatusType;
10 | id?: number;
11 | order?: number;
12 | projectId?: number;
13 | epicId?: number | null;
14 | };
15 |
16 | export type dragRefObjectType = React.MutableRefObject;
17 | export type dragCategoryType = React.MutableRefObject;
18 |
19 | export interface ItemInput {
20 | story: StoryType;
21 | epic: EpicType | undefined;
22 | }
23 |
24 | export interface KanbanType {
25 | category: StatusType;
26 | dragRef: dragRefObjectType;
27 | dragOverRef: dragRefObjectType;
28 | dragCategory: dragCategoryType;
29 | dragOverCategory: dragCategoryType;
30 | }
31 |
32 | //TODO Extends 를 통한 상속
33 | export interface KanbanItemType {
34 | story: StoryType;
35 | epic: EpicType | undefined;
36 | dragRef: dragRefObjectType;
37 | dragOverRef: dragRefObjectType;
38 | dragCategory: dragCategoryType;
39 | dragOverCategory: dragCategoryType;
40 | handleDragDrop(category: StatusType): void;
41 | }
42 |
43 | export interface KanbanTaskType {
44 | name?: string;
45 | id?: number;
46 | preExist?: boolean;
47 | user?: string;
48 | userImage?: string;
49 | userId?: number;
50 | }
51 |
52 | export interface KanbanModalType {
53 | story: StoryType;
54 | isItemModalOpen: boolean;
55 | setModalOpen: (arg: boolean) => void;
56 | }
57 |
58 | export interface TaskProps {
59 | name: string;
60 | id: number;
61 | user?: string;
62 | userImage?: string;
63 | userId?: number;
64 | }
65 |
--------------------------------------------------------------------------------
/client/src/types/svg.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const src: string;
3 | export default src;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/types/task.d.ts:
--------------------------------------------------------------------------------
1 | export interface AllTask {
2 | id: number;
3 | name: string;
4 | status: boolean;
5 | createdAt: string;
6 | updatedAt: string;
7 | projectId?: number;
8 | }
9 |
10 | export interface BackLogTaskProps {
11 | id: number;
12 | name: string;
13 | user: string;
14 | userImage: string;
15 | userId?: number;
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/types/users.d.ts:
--------------------------------------------------------------------------------
1 | import { ProjectType } from '@/types/project';
2 |
3 | export interface UserProfile {
4 | index: number;
5 | name: string;
6 | imageURL: string;
7 | job: string;
8 | admin: boolean;
9 | }
10 |
11 | export interface UserInfoWithProject extends UserProfile {
12 | projects: ProjectType[];
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/types/woff2.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.woff2' {
2 | const value: string;
3 | export default value;
4 | }
5 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.Iterable",
7 | "esnext"
8 | ],
9 | "allowSyntheticDefaultImports": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react",
18 | "esModuleInterop": true,
19 | "strict": true,
20 | "skipLibCheck": true,
21 | "noImplicitAny": true,
22 |
23 | "baseUrl": ".",
24 | "paths": {
25 | "@/*": ["src/*"],
26 |
27 | }
28 | },
29 |
30 | "include": [
31 | "src"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/client/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
4 | const Dotenv = require('dotenv-webpack');
5 |
6 | const loadPlugin = () => {
7 | const plugins = [
8 | new HtmlWebpackPlugin({
9 | template: '/public/index.html',
10 | favicon: 'public/favicon.ico',
11 | minify:
12 | process.env.NODE_ENV === 'production'
13 | ? {
14 | collapseWhitespace: true,
15 | removeComments: true,
16 | }
17 | : false,
18 | }),
19 | new Dotenv(),
20 | ];
21 |
22 | const showBundle = !!process.env.BUNDLE;
23 | if (process.env.BUNDLE) plugins.push(new BundleAnalyzerPlugin());
24 | return plugins;
25 | };
26 |
27 | module.exports = {
28 | mode: 'development',
29 |
30 | entry: path.resolve(__dirname, './src/index'),
31 |
32 | resolve: {
33 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
34 | },
35 |
36 | devServer: {
37 | host: 'localhost',
38 | port: 8080,
39 | historyApiFallback: true,
40 | },
41 |
42 | module: {
43 | rules: [
44 | {
45 | test: /\.(js|jsx|ts|tsx)$/,
46 | loader: 'babel-loader',
47 | exclude: /node_modules/,
48 | },
49 | {
50 | test: /\.(png|jp(e)g|gif|svg|ico)$/,
51 | type: 'asset/resource',
52 | },
53 | {
54 | test: /\.(sa|sc|c)ss$/,
55 | use: ['style-loader', 'css-loader'],
56 | },
57 | {
58 | test: /\.(woff|woff2|eot|ttf|otf)$/,
59 | type: 'asset',
60 | parser: {
61 | dataUrlCondition: {
62 | maxSize: 8 * 1024,
63 | },
64 | },
65 | },
66 | {
67 | resolve: {
68 | alias: {
69 | '@': path.resolve(__dirname, 'src'),
70 | '@public': path.resolve(__dirname, 'public'),
71 | },
72 | },
73 | },
74 | ],
75 | },
76 |
77 | output: {
78 | path: path.resolve(__dirname, 'dist'),
79 | filename: 'bundle.js',
80 | clean: true,
81 | },
82 |
83 | plugins: loadPlugin(),
84 | };
85 |
--------------------------------------------------------------------------------
/drop.sql:
--------------------------------------------------------------------------------
1 | DROP DATABASE HYUPUP;
2 | CREATE DATABASE HYUPUP;
3 | USE HYUPUP;
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "WEB23-HyupUp",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "repository": "https://github.com/boostcampwm-2021/WEB23-HyupUp.git",
6 | "author": "ingong ",
7 | "license": "MIT",
8 | "private": true,
9 | "scripts": {
10 | "dev:client" : "yarn workspace client dev",
11 | "dev:server" : "yarn workspace server dev",
12 | "build:client" : "yarn workspace client build",
13 | "test:server": "yarn workspace server test",
14 | "test:client" : "yarn workspace client test",
15 | "bundle:client" : "yarn workspace client dev:bundle",
16 | "storybook" : "yarn workspace client storybook",
17 | "ci": "echo 'hello' && yarn install --frozen-lockfile",
18 | "lint": "eslint ./"
19 | },
20 | "workspaces": [
21 | "client",
22 | "server"
23 | ],
24 | "devDependencies": {
25 | "@types/jest": "^27.0.2",
26 | "@typescript-eslint/parser": "^5.3.0",
27 | "eslint": "^8.1.0",
28 | "jest": "^27.3.1",
29 | "prettier": "^2.4.1",
30 | "typescript": "^4.4.4"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "env": {
4 | "node": true,
5 | "jest": true
6 | },
7 | "plugins": ["@typescript-eslint", "prettier"],
8 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
9 | "rules": {
10 | "@typescript-eslint/no-unused-vars": "warn",
11 | "no-console": "warn"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/bin/www.ts:
--------------------------------------------------------------------------------
1 | import app from '../app';
2 | import http from 'http';
3 | import debug from 'debug';
4 | import io from '../socket';
5 |
6 | const onError = (err: NodeJS.ErrnoException) => {
7 | if (err.syscall !== 'listen') {
8 | throw err;
9 | }
10 | const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
11 |
12 | // handle specific listen errors with friendly messages
13 | // to-do console.error를 대체할 방법 생각해서 교체하기
14 | switch (err.code) {
15 | case 'EACCES':
16 | console.error(bind + ' requires elevated privileges');
17 | process.exit(1);
18 | break;
19 | case 'EADDRINUSE':
20 | console.error(bind + ' is already in use');
21 | process.exit(1);
22 | break;
23 | default:
24 | throw err;
25 | }
26 | };
27 |
28 | const onListening = () => {
29 | const addr = server.address();
30 | const bind = typeof addr === 'string' ? 'pipe ' + addr : addr ? 'port ' + addr.port : null;
31 | debug('Listening on ' + bind);
32 | };
33 |
34 | const port: number = process.env.PORT ? +process.env.PORT : 3000;
35 | app.set('port', port);
36 |
37 | const server = http.createServer(app);
38 | server.listen(port);
39 | server.on('error', onError);
40 | server.on('listening', onListening);
41 | io.attach(server);
42 |
--------------------------------------------------------------------------------
/server/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | testRegex: '\\.test\\.ts$',
5 | moduleNameMapper: {
6 | '@/(.*)$': '/src/$1',
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/server/lib/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/WEB23-HyupUp/957fb22f18db762b821a7b4d602518d1e1036bdf/server/lib/index.ts
--------------------------------------------------------------------------------
/server/lib/types/index.d.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/WEB23-HyupUp/957fb22f18db762b821a7b4d602518d1e1036bdf/server/lib/types/index.d.ts
--------------------------------------------------------------------------------
/server/lib/types/req-body.d.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/WEB23-HyupUp/957fb22f18db762b821a7b4d602518d1e1036bdf/server/lib/types/req-body.d.ts
--------------------------------------------------------------------------------
/server/lib/types/session.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'express-session' {
2 | interface SessionData {
3 | isLogIn: boolean;
4 | email: string;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/server/lib/types/user.d.ts:
--------------------------------------------------------------------------------
1 | export interface ProjectType {
2 | id: number;
3 | name: string;
4 | }
5 |
6 | export interface PrivateTask {
7 | id: number;
8 | name: string;
9 | status: boolean;
10 | createdAt: Date;
11 | updatedAt: Date;
12 | }
13 |
14 | export interface ProjectTask extends PrivateTask {
15 | project: ProjectType;
16 | }
17 |
18 | export interface User {
19 | id: number;
20 | name: string;
21 | job: string;
22 | email: string;
23 | imageURL: string;
24 | admin: boolean;
25 | organization: number;
26 | projects: Array;
27 | org?: object;
28 | }
29 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "dev": "ts-node-dev -r tsconfig-paths/register --inspect --watch -- ./bin/www.ts",
8 | "test": "jest --setupFiles dotenv/config --forceExit --detectOpenHandles --coverage",
9 | "build": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json"
10 | },
11 | "devDependencies": {
12 | "@types/bcrypt": "^5.0.0",
13 | "@types/connect-history-api-fallback": "^1.3.5",
14 | "@types/connect-redis": "^0.0.17",
15 | "@types/cookie-parser": "^1.4.2",
16 | "@types/debug": "^4.1.7",
17 | "@types/express": "^4.17.13",
18 | "@types/express-session": "^1.17.4",
19 | "@types/http-errors": "^1.8.1",
20 | "@types/jsonwebtoken": "^8.5.6",
21 | "@types/morgan": "^1.9.3",
22 | "@types/nodemailer": "^6.4.4",
23 | "@types/redis": "^2.8.32",
24 | "@types/supertest": "^2.0.11",
25 | "@types/uuid": "^8.3.3",
26 | "supertest": "^6.1.6",
27 | "ts-jest": "^27.0.7",
28 | "ts-node-dev": "^1.1.8",
29 | "tsc-alias": "^1.4.1",
30 | "tsconfig-paths": "^3.12.0"
31 | },
32 | "dependencies": {
33 | "@socket.io/redis-adapter": "^7.0.1",
34 | "bcrypt": "^5.0.1",
35 | "connect-history-api-fallback": "^1.6.0",
36 | "connect-redis": "^6.0.0",
37 | "cookie-parser": "^1.4.5",
38 | "cors": "^2.8.5",
39 | "debug": "^4.3.3",
40 | "dotenv": "^10.0.0",
41 | "express": "^4.17.1",
42 | "express-session": "^1.17.2",
43 | "http": "^0.0.1-security",
44 | "http-errors": "^1.8.0",
45 | "jsonwebtoken": "^8.5.1",
46 | "morgan": "^1.10.0",
47 | "mysql2": "^2.3.2",
48 | "nodemailer": "^6.7.1",
49 | "redis": "^3.1.2",
50 | "reflect-metadata": "^0.1.13",
51 | "socket.io": "^4.3.1",
52 | "typeorm": "^0.2.38",
53 | "uuid": "^8.3.2"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/server/src/Email/Email.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import { inviteByEmail, isValidEmail } from './Email.controller';
4 |
5 | const router = express.Router();
6 |
7 | router.post('/', [authValidator, inviteByEmail]);
8 | router.get('/verify/:token', isValidEmail);
9 |
10 | export default router;
11 |
--------------------------------------------------------------------------------
/server/src/Email/Email.service.ts:
--------------------------------------------------------------------------------
1 | import nodemailer from 'nodemailer';
2 |
3 | /**
4 | * 이메일 주소를 통해 인증 주소를 보내는 함수
5 | * @param to 이메일을 보낼 주소
6 | * @param code 인증을 위한 주소
7 | */
8 | export const sendMail = async (to: string, code: string) => {
9 | const transport = nodemailer.createTransport({
10 | service: 'gmail',
11 | secure: false,
12 | auth: {
13 | user: process.env.GOOGLE_EMAIL,
14 | pass: process.env.GOOGLE_PASSWORD,
15 | },
16 | });
17 |
18 | // TODO email callback url 정하기
19 | const mailOption = {
20 | from: process.env.GOOGLE_EMAIL,
21 | to: to,
22 | subject: 'Hyupup에 당신을 초대합니다.',
23 | html: `
24 | Hyupup에 당신을 초대합니다.
25 | 팀원과 함께 멋진 프로젝트를 완성해보세요!
26 | 당신의 팀 대시보드로 가서 프로젝트를 함께 시작해세요.
27 | `,
28 | };
29 |
30 | transport.sendMail(mailOption, async (err) => {
31 | if (err) throw Error(err.message);
32 | });
33 | };
34 |
--------------------------------------------------------------------------------
/server/src/Epics/Epics.entity.ts:
--------------------------------------------------------------------------------
1 | import Projects from '../Projects/Projects.entity';
2 | import Stories from '../Stories/Stories.entity';
3 | import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
4 |
5 | @Entity({ name: 'EPICS' })
6 | export default class Epics {
7 | @PrimaryGeneratedColumn({ name: 'ID' })
8 | id!: number;
9 |
10 | @Column({ name: 'NAME' })
11 | name!: string;
12 |
13 | @Column({ name: 'START_AT' })
14 | startAt!: Date;
15 |
16 | @Column({ name: 'END_AT' })
17 | endAt!: Date;
18 |
19 | @Column('decimal', { name: 'ORDER', precision: 20, scale: 12 })
20 | order!: number;
21 |
22 | @ManyToOne(() => Projects, (projects) => projects.id)
23 | @JoinColumn({ name: 'PROJECT_ID' })
24 | projects!: Projects;
25 |
26 | @OneToMany(() => Stories, (stories) => stories.id, { cascade: true })
27 | stories!: Stories[];
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/Epics/Epics.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import {
4 | createEpic,
5 | deleteEpicById,
6 | findEpicById,
7 | getAllEpicsByProject,
8 | updateEpicById,
9 | } from './Epics.controller';
10 |
11 | const router = express.Router();
12 |
13 | router.get('/', [authValidator, getAllEpicsByProject]);
14 | router.get('/:id', [authValidator, findEpicById]);
15 | router.patch('/:id', [authValidator, updateEpicById]);
16 | router.delete('/:id', [authValidator, deleteEpicById]);
17 | router.post('/', [authValidator, createEpic]);
18 |
19 | export default router;
20 |
--------------------------------------------------------------------------------
/server/src/Organizations/Organizations.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import { queryValidator } from '@/utils/requestValidator';
3 | import { getRepository } from 'typeorm';
4 | import Organizations from './Organizations.entity';
5 |
6 | export const getOrganizationByName = async (req: Request, res: Response) => {
7 | try {
8 | if (!queryValidator(req.query, ['name'])) throw new Error('query is not valid');
9 | const organizationRepository = getRepository(Organizations);
10 | const organization = await organizationRepository.findOne({ where: { room: req.query.name } });
11 | if (organization) {
12 | res.status(200).end();
13 | } else {
14 | res.status(204).end();
15 | }
16 | } catch (e) {
17 | const err = e as Error;
18 | res.status(400).json({ message: err.message });
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/server/src/Organizations/Organizations.entity.ts:
--------------------------------------------------------------------------------
1 | import Users from '../Users/Users.entity';
2 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
3 |
4 | @Entity({ name: 'ORGANIZATIONS' })
5 | export default class Organizations {
6 | @PrimaryGeneratedColumn({ name: 'ID' })
7 | id!: number;
8 |
9 | @Column({ name: 'ROOM' })
10 | room!: string;
11 |
12 | @OneToMany(() => Users, (users) => users.id)
13 | users!: Users[];
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/Organizations/Organizations.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { getOrganizationByName } from './Organizations.controller';
3 |
4 | const router = express.Router();
5 |
6 | router.get('/', getOrganizationByName);
7 |
8 | export default router;
9 |
--------------------------------------------------------------------------------
/server/src/Projects/Projects.entity.ts:
--------------------------------------------------------------------------------
1 | import Epics from '../Epics/Epics.entity';
2 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
3 |
4 | @Entity({ name: 'PROJECTS' })
5 | export default class Projects {
6 | @PrimaryGeneratedColumn({ name: 'ID' })
7 | id!: number;
8 |
9 | @Column({ name: 'NAME' })
10 | name!: string;
11 |
12 | @OneToMany(() => Epics, (epics) => epics.id)
13 | epics!: Epics[];
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/Projects/Projects.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import {
4 | createProject,
5 | getAllProjectsByUser,
6 | getAllProjectsByOrg,
7 | deleteProjectById,
8 | } from './Projects.controller';
9 |
10 | const router = express.Router();
11 |
12 | router.get('/', [authValidator, getAllProjectsByUser]);
13 | router.get('/:id', [authValidator, getAllProjectsByOrg]);
14 | router.post('/', [authValidator, createProject]);
15 | router.delete('/:id', [authValidator, deleteProjectById]);
16 |
17 | export default router;
18 |
--------------------------------------------------------------------------------
/server/src/Stories/Stories.entity.ts:
--------------------------------------------------------------------------------
1 | import Epics from '../Epics/Epics.entity';
2 | import Tasks from '../Tasks/Tasks.entity';
3 | import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
4 | import Projects from '..//Projects/Projects.entity';
5 |
6 | export enum StatusEnum {
7 | TODO = 'TODO',
8 | IN_PROGRESS = 'IN_PROGRESS',
9 | DONE = 'DONE',
10 | }
11 |
12 | @Entity({ name: 'STORIES' })
13 | export default class Stories {
14 | @PrimaryGeneratedColumn({ name: 'ID' })
15 | id!: number;
16 |
17 | @Column({ name: 'NAME' })
18 | name!: string;
19 |
20 | @Column({ name: 'STATUS', type: 'enum', default: StatusEnum.TODO, enum: StatusEnum })
21 | status!: StatusEnum;
22 |
23 | @Column('decimal', { name: 'ORDER', precision: 20, scale: 12 })
24 | order!: number;
25 |
26 | @ManyToOne(() => Projects, (projects) => projects.id)
27 | @JoinColumn({ name: 'PROJECT_ID' })
28 | projects!: Projects;
29 |
30 | @ManyToOne(() => Epics, (epics) => epics.id, { onDelete: 'SET NULL' })
31 | @JoinColumn({ name: 'EPIC_ID' })
32 | epics!: Epics;
33 |
34 | @OneToMany(() => Tasks, (tasks) => tasks.id, { cascade: true })
35 | tasks!: Tasks[];
36 | }
37 |
--------------------------------------------------------------------------------
/server/src/Stories/Stories.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import {
4 | getAllStoriesByProject,
5 | getStoryById,
6 | postStory,
7 | updateStoryWithName,
8 | updateStoryWithId,
9 | deleteStoryWithId,
10 | } from './Stories.controller';
11 |
12 | const router = express.Router();
13 |
14 | router
15 | .get('/', [authValidator, getAllStoriesByProject])
16 | .get('/:id', [authValidator, getStoryById])
17 | .post('/', [authValidator, postStory])
18 | .patch('/name/:id', [authValidator, updateStoryWithName])
19 | .patch('/order/:id', [authValidator, updateStoryWithId])
20 | .delete('/', [authValidator, deleteStoryWithId]);
21 |
22 | export default router;
23 |
--------------------------------------------------------------------------------
/server/src/Tasks/Tasks.entity.ts:
--------------------------------------------------------------------------------
1 | import Stories from '../Stories/Stories.entity';
2 | import Users from '../Users/Users.entity';
3 | import {
4 | Column,
5 | CreateDateColumn,
6 | Entity,
7 | JoinColumn,
8 | ManyToOne,
9 | PrimaryGeneratedColumn,
10 | UpdateDateColumn,
11 | } from 'typeorm';
12 | import Projects from '../Projects/Projects.entity';
13 |
14 | @Entity({ name: 'TASKS' })
15 | export default class Tasks {
16 | @PrimaryGeneratedColumn({ name: 'ID' })
17 | id!: number;
18 |
19 | @Column({ name: 'NAME' })
20 | name!: string;
21 |
22 | @Column({ name: 'STATUS', type: 'boolean' })
23 | status!: boolean;
24 |
25 | @ManyToOne(() => Projects, (projects) => projects.id)
26 | @JoinColumn({ name: 'PROJECT_ID' })
27 | projects!: Projects;
28 |
29 | @ManyToOne(() => Stories, (stories) => stories.id, { onDelete: 'CASCADE' })
30 | @JoinColumn({ name: 'STORY_ID' })
31 | stories!: Stories;
32 |
33 | @ManyToOne(() => Users, (users) => users.id, { onDelete: 'SET NULL' })
34 | @JoinColumn({ name: 'USER_ID' })
35 | users!: Users;
36 |
37 | @CreateDateColumn()
38 | createdAt!: Date;
39 |
40 | @UpdateDateColumn()
41 | updatedAt!: Date;
42 | }
43 |
--------------------------------------------------------------------------------
/server/src/Tasks/Tasks.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import {
4 | deleteTask,
5 | getAllTasksByProject,
6 | getTasksByStoryId,
7 | updateTask,
8 | postTask,
9 | } from './Tasks.controller';
10 |
11 | const router = express.Router();
12 |
13 | router
14 | .get('/', [authValidator, getAllTasksByProject])
15 | .get('/:id', [authValidator, getTasksByStoryId])
16 | .post('/', [authValidator, postTask])
17 | .patch('/', [authValidator, updateTask])
18 | .delete('/', [authValidator, deleteTask]);
19 |
20 | export default router;
21 |
--------------------------------------------------------------------------------
/server/src/Todo/Todo.entity.ts:
--------------------------------------------------------------------------------
1 | import Users from '../Users/Users.entity';
2 | import {
3 | Column,
4 | CreateDateColumn,
5 | Entity,
6 | JoinColumn,
7 | ManyToOne,
8 | PrimaryGeneratedColumn,
9 | UpdateDateColumn,
10 | } from 'typeorm';
11 |
12 | @Entity({ name: 'TODO' })
13 | export default class Todo {
14 | @PrimaryGeneratedColumn({ name: 'ID' })
15 | id!: number;
16 |
17 | @Column({ name: 'NAME' })
18 | name!: string;
19 |
20 | @Column({ name: 'STATUS', type: 'boolean' })
21 | status!: boolean;
22 |
23 | @ManyToOne(() => Users, (users) => users.id, { onDelete: 'CASCADE' })
24 | @JoinColumn({ name: 'USER_ID' })
25 | users!: Users;
26 |
27 | @CreateDateColumn()
28 | createdAt!: Date;
29 |
30 | @UpdateDateColumn()
31 | updatedAt!: Date;
32 | }
33 |
--------------------------------------------------------------------------------
/server/src/Todo/Todo.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import { createTodo, deleteTodo, updateTodo } from './Todo.controller';
4 |
5 | const router = express.Router();
6 |
7 | router.post('/', [authValidator, createTodo]);
8 | router.patch('/', [authValidator, updateTodo]);
9 | router.delete('/', [authValidator, deleteTodo]);
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/server/src/Users/Users.entity.ts:
--------------------------------------------------------------------------------
1 | import Organizations from '../Organizations/Organizations.entity';
2 | import Tasks from '../Tasks/Tasks.entity';
3 | import {
4 | Column,
5 | Entity,
6 | JoinColumn,
7 | JoinTable,
8 | ManyToMany,
9 | ManyToOne,
10 | OneToMany,
11 | PrimaryGeneratedColumn,
12 | } from 'typeorm';
13 | import Projects from '../Projects/Projects.entity';
14 | import Todo from '../Todo/Todo.entity';
15 |
16 | @Entity({ name: 'USERS' })
17 | export default class Users {
18 | @PrimaryGeneratedColumn({ name: 'ID' })
19 | id!: number;
20 |
21 | @Column({ name: 'JOB' })
22 | job!: string;
23 |
24 | @Column({ name: 'NAME' })
25 | name!: string;
26 |
27 | @Column({ name: 'EMAIL' })
28 | email!: string;
29 |
30 | @Column({ name: 'IMAGE_URL' })
31 | imageURL!: string;
32 |
33 | @Column({ name: 'ADMIN', type: 'boolean' })
34 | admin!: boolean;
35 |
36 | @Column({ name: 'PASSWORD' })
37 | password!: string;
38 |
39 | @ManyToOne(() => Organizations, (org) => org.id)
40 | @JoinColumn({ name: 'ORGANIZATION_ID' })
41 | org!: Organizations;
42 |
43 | @OneToMany(() => Tasks, (tasks) => tasks.id, { cascade: true })
44 | tasks!: Tasks[];
45 | @ManyToMany(() => Projects)
46 | @JoinTable({
47 | name: 'USERS_PROJECTS',
48 | inverseJoinColumn: { name: 'PROJECT_ID', referencedColumnName: 'id' },
49 | joinColumn: { name: 'USER_ID', referencedColumnName: 'id' },
50 | })
51 | projects!: Projects[];
52 |
53 | @OneToMany(() => Todo, (todo) => todo.id, { cascade: true })
54 | todo!: Todo[];
55 | }
56 |
--------------------------------------------------------------------------------
/server/src/Users/Users.router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authValidator } from '@/utils/authValidator';
3 | import {
4 | deleteUserById,
5 | getUsersInfoWithProject,
6 | getUsersByOrganization,
7 | handleGet,
8 | logInUser,
9 | logOut,
10 | signUpUser,
11 | updateUserAdminById,
12 | updateUserWithProject,
13 | getAllTasksById,
14 | } from './Users.controller';
15 |
16 | const router = express.Router();
17 | router.get('/', handleGet);
18 | router.get('/organization', [authValidator, getUsersByOrganization]);
19 | router.get('/tasks', getAllTasksById);
20 | router.get('/:orgId', [authValidator, getUsersInfoWithProject]);
21 |
22 | router.put('/admin/:id', [authValidator, updateUserAdminById]);
23 |
24 | router.delete('/logout', [authValidator, logOut]);
25 | router.delete('/:id', [authValidator, deleteUserById]);
26 |
27 | router.post('/login', [logInUser, handleGet]);
28 | router.post('/signup', [signUpUser, handleGet]);
29 |
30 | router.patch('/project', updateUserWithProject);
31 |
32 | export default router;
33 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import Epics from './Epics/Epics.entity';
2 | import Organizations from './Organizations/Organizations.entity';
3 | import Projects from './Projects/Projects.entity';
4 | import Stories from './Stories/Stories.entity';
5 | import Tasks from './Tasks/Tasks.entity';
6 | import Users from './Users/Users.entity';
7 | import Todo from './Todo/Todo.entity';
8 |
9 | export const entities = [Epics, Organizations, Projects, Stories, Tasks, Users, Todo];
10 |
--------------------------------------------------------------------------------
/server/src/utils/authValidator.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 |
3 | export const authValidator = (req: Request, res: Response, next: NextFunction) => {
4 | try {
5 | if (!req.session.isLogIn) throw new Error('session is not valid');
6 | next();
7 | } catch (e) {
8 | res.status(401).end();
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/server/src/utils/requestValidator.ts:
--------------------------------------------------------------------------------
1 | import * as core from 'express-serve-static-core';
2 |
3 | export function queryValidator(query: core.Query, properties: Array) {
4 | return (
5 | properties.length === properties.filter((el) => el in query && query[el] !== undefined).length
6 | );
7 | }
8 |
9 | export function bodyValidator(body: any, properties: Array) {
10 | return (
11 | properties.length === properties.filter((el) => el in body && body[el] !== undefined).length
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "downlevelIteration": true,
6 | "isolatedModules": true,
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "experimentalDecorators": true,
10 | "emitDecoratorMetadata": true,
11 | "skipLibCheck": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "outDir": "dist",
14 | "baseUrl": "./",
15 | "paths": {"@/*":["src/*"]},
16 | },
17 | "exclude": ["test"],
18 | "include": ["src", "socket.ts", "app.ts", "bin"]
19 | }
20 |
--------------------------------------------------------------------------------