├── .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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/calendar-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/cancel-icon-circled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/cancel-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/delete-icon-red.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/delete-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/draggable.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/public/icons/drop-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/file-copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/file-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/home-active-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/home-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/image-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/meatball-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/minus-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/more_horiz.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/personal-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/plus-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/project-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/search-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/setting-active-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/setting-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/team-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/time-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/white-add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/work-active-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/icons/work-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | info icon 17 | 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) =>