diff --git a/.github/ISSUE_TEMPLATE/Bug-Report.yml b/.github/ISSUE_TEMPLATE/Bug-Report.yml
index 779477d..382a38e 100644
--- a/.github/ISSUE_TEMPLATE/Bug-Report.yml
+++ b/.github/ISSUE_TEMPLATE/Bug-Report.yml
@@ -3,6 +3,17 @@ description: 기능 개선을 위해 버그를 제보해주세요.
title: "fix: "
labels: ["bug"]
body:
+- type: dropdown
+ attributes:
+ label: 심각성
+ description: 해당 버그가 서비스에 미치는 영향의 심각성을 평가해 주세요.
+ multiple: false
+ options:
+ - A
+ - B
+ - C
+ validations:
+ required: true
- type: textarea
attributes:
label: 버그 설명
diff --git a/.github/ISSUE_TEMPLATE/Feature-Request.yml b/.github/ISSUE_TEMPLATE/Feature-Request.yml
new file mode 100644
index 0000000..c52b8ba
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Feature-Request.yml
@@ -0,0 +1,63 @@
+name: 피처 요청 템플릿
+description: 서비스에 필요한 새로운 피처 명세를 작성하고 개발자에게 요청하세요.
+title: "request: "
+labels: ["request"]
+body:
+- type: input
+ attributes:
+ label: 피처 개요
+ description: 필요한 피처에 대해서 한줄로 설명해주세요.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: 요청 사유
+ description: 해당 피처가 필요한 이유, 필요한 상황에 대해서 기술해주세요.
+ validations:
+ required: true
+- type: dropdown
+ attributes:
+ label: 중요도
+ description: 해당 피처의 중요도를 선택해주세요.
+ multiple: false
+ options:
+ - A
+ - B
+ - C
+ validations:
+ required: true
+- type: dropdown
+ attributes:
+ label: 난이도
+ description: 해당 피처의 구현 난이도(구현 시간도 고려하여)을 선택해주세요.
+ multiple: false
+ options:
+ - 상
+ - 중
+ - 하
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: 피처 설명
+ description: 필요한 피처에 대해서 자세히 설명해주세요.
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: 체크리스트
+ description: 작업에 필요한 마이크로 피처들을 작성해 이슈를 링크해주세요.
+ validations:
+ required: false
+- type: input
+ attributes:
+ label: 관련 정보
+ description: 관련된 Project Wiki 페이지가 있다면 링크해주세요.
+ validations:
+ required: false
+- type: checkboxes
+ attributes:
+ label: 담당자 지정
+ options:
+ - label: assignee에 담당자를 제대로 지정하였나요?
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/Feature.yml b/.github/ISSUE_TEMPLATE/Feature.yml
index f4be209..3c48703 100644
--- a/.github/ISSUE_TEMPLATE/Feature.yml
+++ b/.github/ISSUE_TEMPLATE/Feature.yml
@@ -3,10 +3,32 @@ description: 피처 구현을 위한 이슈를 작성하세요.
title: "feat: "
labels: ["feature"]
body:
-- type: textarea
+- type: input
attributes:
label: 피처 개요
- description: 필요한 피처에 대해서 간단히 설명해주세요.
+ description: 필요한 피처에 대해서 한줄로 설명해주세요.
+ validations:
+ required: true
+- type: dropdown
+ attributes:
+ label: 중요도
+ description: 해당 피처의 중요도를 선택해주세요.
+ multiple: false
+ options:
+ - A
+ - B
+ - C
+ validations:
+ required: true
+- type: dropdown
+ attributes:
+ label: 난이도
+ description: 해당 피처의 구현 난이도(구현 시간도 고려하여)을 선택해주세요.
+ multiple: false
+ options:
+ - 상
+ - 중
+ - 하
validations:
required: true
- type: textarea
@@ -26,4 +48,4 @@ body:
label: 관련 정보
description: 관련된 Project Wiki 페이지가 있다면 링크해주세요.
validations:
- required: false
\ No newline at end of file
+ required: false
diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md
index 9aeae00..e4e1bfb 100644
--- a/.github/pull-request-template.md
+++ b/.github/pull-request-template.md
@@ -1,17 +1,13 @@
-
## 개요
-무엇에 관한 내용인지
+
## 세부 내용
-- A
- - description
- - screenshot
-- B
- - 변경 전
- - 변경 후
-- C
## 공유
+
- 고민과 질문
+
- 해결 과정의 기록은 정리해서 위키에 수록 후 링크 달기
-## relevant issue number
+
+## 관련 이슈
+
- 관련된 이슈 넘버가 있으면 이곳에 기입해주세요
diff --git a/.github/workflows/CLIENT_BUILD.yml b/.github/workflows/CLIENT_BUILD.yml
index 8703161..7850337 100644
--- a/.github/workflows/CLIENT_BUILD.yml
+++ b/.github/workflows/CLIENT_BUILD.yml
@@ -13,6 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v2
+ - name: Runner 타임존 설정
+ run: |
+ sudo timedatectl set-timezone 'Asia/Seoul'
+
- name: Node 설정
uses: actions/setup-node@v1
with:
@@ -30,6 +34,25 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: yarn
+ - name: 테스트 실행
+ working-directory: ./client
+ run: |
+ yarn -s test
+
+ - name: 테스트 결과 발행
+ uses: n-ryu/test-reporter@v0.0.2
+ if: always()
+ with:
+ name: JEST Tests
+ path: client/reports/*.xml
+ reporter: jest-junit
+
+ - name: Code Coverage 리포트 발행
+ if: always()
+ uses: codecov/codecov-action@v3
+ with:
+ flags: unittests
+
- name: Client 소스 빌드
working-directory: "./client"
run: yarn build
@@ -61,7 +84,7 @@ jobs:
username: ${{ secrets.RELEASE_USERNAME }}
password: ${{ secrets.RELEASE_PASSWORD }}
port: ${{ secrets.RELEASE_PORT }}
- source: "docker-compose.yml"
+ source: "docker-compose.production.yml"
target: "oao"
- name: 운영 서버에서 Docker Compose 실행
@@ -74,11 +97,10 @@ jobs:
script: |
echo ${{secrets.CONTAINER_REGISTRY_TOKEN}} | docker login ghcr.io -u kumsil1006 --password-stdin
docker pull ghcr.io/kumsil1006/oao-client
- docker pull ghcr.io/kumsil1006/oao-proxy
cd oao
- docker-compose up -d
+ docker-compose -f docker-compose.production.yml up -d
docker image prune
- name: 실패시 슬랙 메시지 전송
diff --git a/.github/workflows/CLIENT_DEV_BUILD.yml b/.github/workflows/CLIENT_DEV_BUILD.yml
index c011ae4..fdc3166 100644
--- a/.github/workflows/CLIENT_DEV_BUILD.yml
+++ b/.github/workflows/CLIENT_DEV_BUILD.yml
@@ -3,7 +3,7 @@ on:
push:
branches:
- main
-
+
paths:
- "client/**"
@@ -14,6 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v2
+ - name: Runner 타임존 설정
+ run: |
+ sudo timedatectl set-timezone 'Asia/Seoul'
+
- name: Node 설정
uses: actions/setup-node@v1
with:
@@ -31,6 +35,25 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: yarn
+ - name: 테스트 실행
+ working-directory: ./client
+ run: |
+ yarn -s test
+
+ - name: 테스트 결과 발행
+ uses: n-ryu/test-reporter@v0.0.2
+ if: always()
+ with:
+ name: JEST Tests
+ path: client/reports/*.xml
+ reporter: jest-junit
+
+ - name: Code Coverage 리포트 발행
+ if: always()
+ uses: codecov/codecov-action@v3
+ with:
+ flags: unittests
+
- name: Client 소스 build
working-directory: "./client"
run: yarn build
@@ -49,7 +72,7 @@ jobs:
tags: ghcr.io/kumsil1006/oao-dev-client
context: ./client
- - name: Docker Compose 파일 운영 서버로 복사
+ - name: Docker Compose 파일 개발 서버로 복사
uses: appleboy/scp-action@master
with:
host: ${{ secrets.ANOTHER_HOST }}
@@ -59,7 +82,7 @@ jobs:
source: "docker-compose.yml"
target: "oao"
- - name: 운영 서버에서 Docker Compose 실행
+ - name: 개발 서버에서 Docker Compose 실행
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.ANOTHER_HOST }}
@@ -69,7 +92,6 @@ jobs:
script: |
echo ${{secrets.CONTAINER_REGISTRY_TOKEN}} | docker login ghcr.io -u kumsil1006 --password-stdin
docker pull ghcr.io/kumsil1006/oao-dev-client
- docker pull ghcr.io/kumsil1006/oao-dev-proxy
cd oao
diff --git a/.github/workflows/PRODUCTION_RELEASE.yml b/.github/workflows/PRODUCTION_RELEASE.yml
index 0f4a9de..7b7695c 100644
--- a/.github/workflows/PRODUCTION_RELEASE.yml
+++ b/.github/workflows/PRODUCTION_RELEASE.yml
@@ -1,4 +1,4 @@
-name: Release Github Application Version
+name: Release Github Application Version Update
on:
pull_request:
branches:
diff --git a/.github/workflows/PROXY_BUILD.yml b/.github/workflows/PROXY_BUILD.yml
new file mode 100644
index 0000000..f9d34dc
--- /dev/null
+++ b/.github/workflows/PROXY_BUILD.yml
@@ -0,0 +1,68 @@
+name: Proxy Dockerfile Build
+on:
+ pull_request:
+ branches:
+ - release
+ paths:
+ - "nginx/Dockerfile.production"
+
+jobs:
+ proxy-build:
+ name: proxy-build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: GitHub Container Registry 로그인
+ uses: docker/login-action@v1
+ with:
+ registry: ghcr.io
+ username: kumsil1006
+ password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
+
+ - name: Docker images build 및 GitHub Container Registry로 push
+ uses: docker/build-push-action@v3.2.0
+ with:
+ push: true
+ tags: ghcr.io/kumsil1006/oao-proxy
+ context: ./nginx
+ file: ./nginx/Dockerfile.production
+
+ - name: Docker Compose 파일 운영 서버로 복사
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.RELEASE_HOST }}
+ username: ${{ secrets.RELEASE_USERNAME }}
+ password: ${{ secrets.RELEASE_PASSWORD }}
+ port: ${{ secrets.RELEASE_PORT }}
+ source: "docker-compose.production.yml"
+ target: "oao"
+
+ - name: 운영 서버에서 Docker Compose 실행
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.RELEASE_HOST }}
+ username: ${{ secrets.RELEASE_USERNAME }}
+ password: ${{ secrets.RELEASE_PASSWORD }}
+ port: ${{ secrets.RELEASE_PORT }}
+ script: |
+ echo ${{secrets.CONTAINER_REGISTRY_TOKEN}} | docker login ghcr.io -u kumsil1006 --password-stdin
+ docker pull ghcr.io/kumsil1006/oao-proxy
+
+ cd oao
+
+ docker-compose -f docker-compose.production.yml up -d
+ docker image prune
+
+ - name: 실패시 슬랙 메시지 전송
+ if: ${{ failure() }}
+ uses: ./.github/actions/slack-notify
+ with:
+ slack_incoming_url: ${{ secrets.SLACK_INCOMING_URL }}
+
+ - name: 성공시 슬랙 메시지 전송
+ if: ${{ success() }}
+ uses: ./.github/actions/slack-notify
+ with:
+ status: success
+ slack_incoming_url: ${{ secrets.SLACK_INCOMING_URL }}
diff --git a/.github/workflows/PROXY_DEV_BUILD.yml b/.github/workflows/PROXY_DEV_BUILD.yml
new file mode 100644
index 0000000..df337eb
--- /dev/null
+++ b/.github/workflows/PROXY_DEV_BUILD.yml
@@ -0,0 +1,67 @@
+name: Proxy Dev Dockerfile Build
+on:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - "nginx/Dockerfile"
+
+jobs:
+ proxy-build:
+ name: proxy-build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: GitHub Container Registry 로그인
+ uses: docker/login-action@v1
+ with:
+ registry: ghcr.io
+ username: kumsil1006
+ password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
+
+ - name: Docker images build 및 GitHub Container Registry로 push
+ uses: docker/build-push-action@v3.2.0
+ with:
+ push: true
+ tags: ghcr.io/kumsil1006/oao-dev-proxy
+ context: ./nginx
+
+ - name: Docker Compose 파일 개발 서버로 복사
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.ANOTHER_HOST }}
+ username: ${{ secrets.ANOTHER_USERNAME }}
+ password: ${{ secrets.ANOTHER_PASSWORD }}
+ port: ${{ secrets.ANOTHER_PORT }}
+ source: "docker-compose.yml"
+ target: "oao"
+
+ - name: 개발 서버에서 Docker Compose 실행
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.ANOTHER_HOST }}
+ username: ${{ secrets.ANOTHER_USERNAME }}
+ password: ${{ secrets.ANOTHER_PASSWORD }}
+ port: ${{ secrets.ANOTHER_PORT }}
+ script: |
+ echo ${{secrets.CONTAINER_REGISTRY_TOKEN}} | docker login ghcr.io -u kumsil1006 --password-stdin
+ docker pull ghcr.io/kumsil1006/oao-dev-proxy
+
+ cd oao
+
+ docker-compose up -d
+ docker image prune
+
+ - name: 실패시 슬랙 메시지 전송
+ if: ${{ failure() }}
+ uses: ./.github/actions/slack-notify
+ with:
+ slack_incoming_url: ${{ secrets.SLACK_INCOMING_URL }}
+
+ - name: 성공시 슬랙 메시지 전송
+ if: ${{ success() }}
+ uses: ./.github/actions/slack-notify
+ with:
+ status: success
+ slack_incoming_url: ${{ secrets.SLACK_INCOMING_URL }}
diff --git a/.github/workflows/SERVER_BUILD.yml b/.github/workflows/SERVER_BUILD.yml
index 0b0f24a..e20a8e6 100644
--- a/.github/workflows/SERVER_BUILD.yml
+++ b/.github/workflows/SERVER_BUILD.yml
@@ -53,7 +53,7 @@ jobs:
username: ${{ secrets.RELEASE_USERNAME }}
password: ${{ secrets.RELEASE_PASSWORD }}
port: ${{ secrets.RELEASE_PORT }}
- source: "docker-compose.yml"
+ source: "docker-compose.production.yml"
target: "oao"
- name: 운영 서버에서 Docker Compose 실행
@@ -66,11 +66,10 @@ jobs:
script: |
echo ${{secrets.CONTAINER_REGISTRY_TOKEN}} | docker login ghcr.io -u kumsil1006 --password-stdin
docker pull ghcr.io/kumsil1006/oao-server
- docker pull ghcr.io/kumsil1006/oao-proxy
cd oao
- docker-compose up -d
+ docker-compose -f docker-compose.production.yml up -d
docker image prune
- name: 실패시 슬랙 메시지 전송
diff --git a/.github/workflows/SERVER_DEV_BUILD.yml b/.github/workflows/SERVER_DEV_BUILD.yml
index 4520002..1a3dfbd 100644
--- a/.github/workflows/SERVER_DEV_BUILD.yml
+++ b/.github/workflows/SERVER_DEV_BUILD.yml
@@ -40,7 +40,7 @@ jobs:
push: true
tags: ghcr.io/kumsil1006/oao-dev-server
- - name: Docker Compose 파일 운영 서버로 복사
+ - name: Docker Compose 파일 개발 서버로 복사
uses: appleboy/scp-action@master
with:
host: ${{ secrets.ANOTHER_HOST }}
@@ -50,7 +50,7 @@ jobs:
source: "docker-compose.yml"
target: "oao"
- - name: 운영 서버에서 Docker Compose 실행
+ - name: 개발 서버에서 Docker Compose 실행
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.ANOTHER_HOST }}
@@ -60,7 +60,6 @@ jobs:
script: |
echo ${{secrets.CONTAINER_REGISTRY_TOKEN}} | docker login ghcr.io -u kumsil1006 --password-stdin
docker pull ghcr.io/kumsil1006/oao-dev-server
- docker pull ghcr.io/kumsil1006/oao-dev-proxy
cd oao
diff --git a/.github/workflows/TEST_REPORT.yml b/.github/workflows/TEST_REPORT.yml
index 3ad4453..513b180 100644
--- a/.github/workflows/TEST_REPORT.yml
+++ b/.github/workflows/TEST_REPORT.yml
@@ -1,3 +1,4 @@
+name: test-report
on:
pull_request:
branches:
@@ -33,8 +34,8 @@ jobs:
yarn -s test
- name: 테스트 결과 발행
- uses: n-ryu/test-reporter@v0.0.1
- if: success() || failure()
+ uses: n-ryu/test-reporter@v0.0.2
+ if: always()
with:
name: JEST Tests # Name of the check run which will be created
path: client/reports/*.xml # Path to test results
@@ -44,4 +45,4 @@ jobs:
if: always()
uses: codecov/codecov-action@v3
with:
- flags: unittests # opti
\ No newline at end of file
+ flags: unittests # opti
diff --git a/client/codecov b/client/codecov
deleted file mode 100755
index 116b23b..0000000
Binary files a/client/codecov and /dev/null differ
diff --git a/client/default.conf b/client/default.conf
index c3f2f6d..802684d 100644
--- a/client/default.conf
+++ b/client/default.conf
@@ -1,7 +1,7 @@
server {
listen 3000;
+ root /dist;
location / {
- root /dist;
- index index.html index.htm;
+ try_files $uri $uri/ $uri.html /index.html;
}
-}
+}
\ No newline at end of file
diff --git a/client/index.html b/client/index.html
index b0d477b..a21e4f2 100644
--- a/client/index.html
+++ b/client/index.html
@@ -3,7 +3,13 @@
+
+
+
OAO
+
+
+
diff --git a/client/src/App.tsx b/client/src/App.tsx
index c11f7d0..2cad04c 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,36 +1,98 @@
-import { ReactElement } from 'react';
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
-import { Provider } from 'jotai';
+import { ReactElement, Suspense, useState, useEffect } from 'react';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import styled from 'styled-components';
import { ToastContainer } from 'react-toastify';
+import { useAtomValue } from 'jotai';
-import Header from './container/Header';
-import Menubar from './container/Menubar';
+import Header from '@container/Header';
+import Menubar from '@container/Menubar';
+import Main from '@page/Main';
import Todos from '@page/Todos';
-import Main from './page/Main';
+import DiagramPage from '@page/DiagramPage';
import OverLay from '@components/OverLay';
+import TodoController from '@container/TodoController';
+
+import { TutorialImage } from '@components/tutorial/TutorialImage';
+import { isTutorialAtom } from '@util/GlobalState';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const RowWrapper = styled.div`
+ position: relative;
+ width: calc(100%);
+ display: flex;
+`;
const Wrapper = styled.div`
+ position: relative;
+ width: calc(100% - 64px);
+ display: flex;
+ flex-direction: column;
+`;
+
+const TutorialRadialOverlay = styled.div`
+ position: absolute;
width: 100%;
+ height: 100%;
+ left: 0px;
+ top: 0px;
+
+ background: linear-gradient(180deg, ${PRIMARY_COLORS.green}00 73.51%, #93c692 127.82%);
+
+ pointer-events: none;
+
+ p {
+ position: absolute;
+ left: 10vh;
+ bottom: 10vh;
+ font-size: 80px;
+ opacity: 0.7;
+ font-family: 'Roboto';
+ color: ${PRIMARY_COLORS.green};
+ }
`;
const App = (): ReactElement => {
+ const isTutorial = useAtomValue(isTutorialAtom);
+ const [isOver, setIsOver] = useState(false);
+ const isShow = isTutorial && !isOver;
+
+ useEffect(() => {
+ if (!isTutorial) {
+ setIsOver(false);
+ }
+ }, [isTutorial]);
+
+ const isCorrectURL = isTutorial && location.pathname.includes('tutorials');
return (
-
+ loading App}>
-
-
-
-
-
- }>
- }>
-
-
+
+
+
+
+
+ }>
+ }>
+ }>
+ : }>
+ : }>
+ : }>
+
+
+
+ {isShow && }
+ {isTutorial && (
+
+
+ 튜토리얼 중입니다...
+
+
+ )}
+
-
+
);
};
diff --git a/client/src/components/Bubble.tsx b/client/src/components/Bubble.tsx
new file mode 100644
index 0000000..f605faf
--- /dev/null
+++ b/client/src/components/Bubble.tsx
@@ -0,0 +1,55 @@
+import { ReactElement, ReactNode } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { white, gray } = PRIMARY_COLORS;
+
+const Wrapper = styled.div<{ color?: string; backgroundColor?: string }>`
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: max-content;
+ height: max-content;
+ border-radius: 8px;
+ padding-block: 12px;
+ padding-inline: 12px;
+ color: ${(props) => props.color ?? white};
+ background-color: ${(props) => props.backgroundColor ?? gray};
+ gap: 10px;
+ filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25));
+ transform: translate(20px, -50%);
+
+ &::after {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 0;
+ right: 100%;
+ top: 50%;
+ transform: translate(0, -50%);
+ border-top: 7px solid transparent;
+ border-right: 8.5px solid ${(props) => props.backgroundColor ?? gray};
+ border-bottom: 7px solid transparent;
+ border-left: 0px solid transparent;
+ filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25));
+ }
+`;
+
+const Bubble = ({
+ children,
+ color,
+ backgroundColor,
+}: {
+ children: ReactNode;
+ color?: string;
+ backgroundColor?: string;
+}): ReactElement => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default Bubble;
diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx
index cead737..0802465 100644
--- a/client/src/components/Button.tsx
+++ b/client/src/components/Button.tsx
@@ -1,4 +1,4 @@
-import { FC, ReactElement, memo } from 'react';
+import { FC, ReactElement, memo, ReactNode } from 'react';
import styled from 'styled-components';
interface StyleProps {
@@ -9,10 +9,11 @@ interface StyleProps {
borderRadius?: string;
margin?: string;
flexGrow?: number;
+ children?: ReactNode;
}
interface Props extends StyleProps {
- context: string | ReactElement;
+ context?: string | ReactElement;
onClick?: (e: React.MouseEvent) => void;
}
@@ -26,10 +27,11 @@ const StyledButton = styled.button`
flex-grow: ${({ flexGrow }) => flexGrow};
`;
-const Button: FC = ({ context, onClick, ...props }) => {
+const Button: FC = ({ context, onClick, children, ...props }) => {
return (
{context}
+ {children}
);
};
diff --git a/client/src/components/ElapsedTimeText.tsx b/client/src/components/ElapsedTimeText.tsx
new file mode 100644
index 0000000..4d645fa
--- /dev/null
+++ b/client/src/components/ElapsedTimeText.tsx
@@ -0,0 +1,17 @@
+import Text from '@components/Text';
+
+import { displayTimeAtom, elapsedTimeAtom } from '@util/GlobalState';
+import { useAtom, useAtomValue } from 'jotai';
+import { ReactElement, memo, useEffect } from 'react';
+
+const ElapsedTimeText = ({ color }: { color: string }): ReactElement => {
+ const elapsedTime = useAtomValue(elapsedTimeAtom);
+ const [displayTime, setDisplayTime] = useAtom(displayTimeAtom);
+
+ useEffect(() => {
+ setDisplayTime();
+ }, [elapsedTime]);
+
+ return ;
+};
+export default memo(ElapsedTimeText);
diff --git a/client/src/components/Modal.tsx b/client/src/components/Modal.tsx
new file mode 100644
index 0000000..4bce217
--- /dev/null
+++ b/client/src/components/Modal.tsx
@@ -0,0 +1,99 @@
+import { memo, ReactElement, ReactNode, SetStateAction, Dispatch, MutableRefObject, useRef } from 'react';
+import styled from 'styled-components';
+
+import { PRIMARY_COLORS } from '@util/Constants';
+import { getModalValues } from '@util/Common';
+
+import Button from '@components/Button';
+import Text from '@components/Text';
+import OverLay from '@components/OverLay';
+
+import 'react-toastify/dist/ReactToastify.css';
+
+const { offWhite, red, blue, darkGray, lightGray } = PRIMARY_COLORS;
+
+interface WrapperProps {
+ ref: any;
+}
+
+const Wrapper = styled.div`
+ width: 50vw;
+ height: max-content;
+ max-height: 80vh;
+ overflow-y: auto;
+ position: relative;
+ background-color: ${offWhite};
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
+ border-radius: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 30px;
+ gap: 10px;
+
+ input {
+ width: 100%;
+ color: ${darkGray};
+ font-family: 'SanSerif';
+ font-size: 15px;
+ padding: 5px;
+ border: 1px solid ${lightGray};
+ border-radius: 5px;
+ outline: none;
+ }
+
+ textarea {
+ resize: none;
+ }
+`;
+
+const ButtonWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ gap: 15px;
+ padding-top: 15px;
+ text-align: right;
+ justify-content: flex-end;
+`;
+
+interface ModalProps {
+ modalHeader: string;
+ action: Function;
+ setIsModalOpen: Dispatch>;
+ children?: ReactNode;
+ ref?: MutableRefObject;
+ editingTodoId?: string;
+}
+
+const Modal = ({ modalHeader, action, setIsModalOpen, children, editingTodoId }: ModalProps): ReactElement => {
+ const modalWrapper = useRef();
+
+ const handleOnConfirm = (): void => {
+ if (modalWrapper.current === undefined) {
+ return;
+ }
+ const inputData = getModalValues(modalWrapper.current);
+ action(inputData, setIsModalOpen, editingTodoId);
+ };
+
+ return (
+
+
+
+ {children}
+
+ }
+ onClick={() => setIsModalOpen(false)}
+ />
+ }
+ onClick={handleOnConfirm}
+ />
+
+
+
+ );
+};
+
+export default memo(Modal);
diff --git a/client/src/components/OverLay.tsx b/client/src/components/OverLay.tsx
index 5578c65..04a3b76 100644
--- a/client/src/components/OverLay.tsx
+++ b/client/src/components/OverLay.tsx
@@ -1,10 +1,11 @@
-import { TABLE_MODALS } from '@util/Constants';
-import { modalTypeAtom } from '@util/GlobalState';
-import { useAtom } from 'jotai';
-import { ReactElement } from 'react';
+import { Dispatch, MouseEvent, ReactElement, ReactNode, SetStateAction, useRef } from 'react';
import styled from 'styled-components';
-const StyledOverlay = styled.div`
+interface WrapperProps {
+ ref: any;
+}
+
+const StyledOverlay = styled.div`
background-color: rgba(0, 0, 0, 0.7);
width: 100vw;
height: 100vh;
@@ -13,19 +14,31 @@ const StyledOverlay = styled.div`
left: 0;
bottom: 0;
right: 0;
- z-index: 10;
+ z-index: 100000000;
+ display: flex;
+ justify-content: center;
+ align-items: center;
`;
-const { none } = TABLE_MODALS;
+interface ModalProps {
+ setHasModal: Dispatch>;
+ children: ReactNode;
+}
-const OverLay = (): ReactElement => {
- const [modalType, setModalType] = useAtom(modalTypeAtom);
+const OverLay = ({ setHasModal, children }: ModalProps): ReactElement => {
+ const overLayRef = useRef();
- const hanldeOnClick = (): void => {
- setModalType(none);
+ const handle = (e: MouseEvent): void => {
+ if (e.target === overLayRef.current) {
+ setHasModal(false);
+ }
};
- return <>{modalType !== none && }>;
+ return (
+
+ {children}
+
+ );
};
export default OverLay;
diff --git a/client/src/components/Search.tsx b/client/src/components/Search.tsx
new file mode 100644
index 0000000..d57e89b
--- /dev/null
+++ b/client/src/components/Search.tsx
@@ -0,0 +1,160 @@
+import { PlainTodo } from '@todo/todo.type';
+import { INDEX, KEYBOARD_EVENT_KEY, PRIMARY_COLORS } from '@util/Constants';
+import { todoList } from '@util/GlobalState';
+import { useAtomValue } from 'jotai';
+import { ChangeEvent, ReactElement, useEffect, useRef, useState } from 'react';
+import { toast } from 'react-toastify';
+import styled from 'styled-components';
+import SearchBar from './SearchBar';
+import SearchListContent from './SearchListContent';
+
+const { lightGray } = PRIMARY_COLORS;
+
+interface LiRefList {
+ [key: string]: HTMLLIElement;
+}
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 100%;
+`;
+const Ul = styled.ul`
+ position: absolute;
+ z-index: 110;
+ width: 100%;
+ margin: 8px 0;
+ padding-left: 0px;
+ background-color: white;
+ border: 1px solid #e2e2e2;
+ border-radius: 5px;
+ overflow: auto;
+ max-height: 20vh;
+ list-style: none;
+ li {
+ margin: 5px 0;
+ }
+ li:hover {
+ cursor: pointer;
+ background-color: ${lightGray};
+ }
+`;
+
+const BlankSearchListInfo = styled.li`
+ border: 1px solid #e2e2e2;
+ padding: 10px;
+`;
+
+const getNowIndexedId = (nowIndex: number, keyName: string, searchTodoList: PlainTodo[]): string => {
+ const searchListLastIndex = searchTodoList.length - 1;
+ const firstIndex = keyName === KEYBOARD_EVENT_KEY.DOWN ? INDEX.FIRST : searchListLastIndex;
+ const lastIndex = keyName === KEYBOARD_EVENT_KEY.DOWN ? searchListLastIndex : INDEX.FIRST;
+ const nextDirection = keyName === KEYBOARD_EVENT_KEY.DOWN ? 1 : -1;
+
+ const focusedId =
+ nowIndex === INDEX.NOT_FOUND || nowIndex === lastIndex
+ ? searchTodoList[firstIndex].id
+ : searchTodoList[nowIndex + nextDirection].id;
+ return focusedId;
+};
+
+const Search = ({
+ onClick,
+ onChange,
+}: {
+ onClick: Function;
+ onChange: (e: ChangeEvent) => void;
+}): ReactElement => {
+ const todoListAtom = useAtomValue(todoList);
+ const [inputValue, setInputValue] = useState('');
+ const [searchTodoList, setSearchTodoList] = useState([]);
+ const [focusedId, setFocusedId] = useState('');
+ const liRef = useRef({});
+ const [isKeywordNotRegExcep, setIsKeywordNotRegExcep] = useState(true);
+
+ useEffect(() => {
+ setIsKeywordNotRegExcep(inputValue.match(/[.*+?^${}()|[\]\\]/) === null);
+
+ if (inputValue === '' || !isKeywordNotRegExcep) return setSearchTodoList([]);
+
+ todoListAtom
+ .getTodoBySearchKeyword(inputValue)
+ .then((data: PlainTodo[]) => {
+ setSearchTodoList(() => [...data]);
+ })
+ .catch((err) => {
+ toast.error(err);
+ });
+ }, [inputValue]);
+
+ const initSearchBar = (): void => {
+ setInputValue('');
+ setSearchTodoList([]);
+ setFocusedId('');
+ };
+
+ const searchBarOnInput = (event: React.ChangeEvent): void => {
+ setInputValue(event.target.value);
+ };
+
+ const searchBarOnKeyDown = (event: KeyboardEvent): void => {
+ if (searchTodoList.length === 0) return;
+ const nowIndex = searchTodoList.findIndex((el) => el.id === focusedId);
+
+ if (event.key === KEYBOARD_EVENT_KEY.DOWN || event.key === KEYBOARD_EVENT_KEY.UP) {
+ event.preventDefault();
+ const focusedId = getNowIndexedId(nowIndex, event.key, searchTodoList);
+ setFocusedId(focusedId);
+ scrollFocusedIdList(focusedId);
+ }
+ if (event.key === KEYBOARD_EVENT_KEY.ENTER && focusedId !== '') {
+ const selectTodo = searchTodoList.find((el) => el.id === focusedId);
+ onClick(selectTodo);
+ initSearchBar();
+ }
+ };
+
+ const scrollFocusedIdList = (todoId: string): void => {
+ todoId === ''
+ ? liRef.current[searchTodoList[0].id].scrollIntoView({ block: 'center' })
+ : liRef.current[todoId].scrollIntoView({ block: 'center' });
+ };
+
+ const listOnClick = (selectTodo: PlainTodo): void => {
+ onClick(selectTodo);
+ initSearchBar();
+ };
+
+ return (
+
+
+
+ {searchTodoList.map((todo) => {
+ return (
+ - {
+ if (el !== null) {
+ liRef.current[todo.id] = el;
+ }
+ }}
+ style={{ backgroundColor: focusedId === todo.id ? lightGray : '' }}
+ onClick={() => listOnClick(todo)}
+ >
+
+
+ );
+ })}
+ {searchTodoList.length === 0 && inputValue !== '' && isKeywordNotRegExcep && (
+ 검색한 할 일이 없습니다
+ )}
+
+
+ );
+};
+
+export default Search;
diff --git a/client/src/components/SearchBar.tsx b/client/src/components/SearchBar.tsx
new file mode 100644
index 0000000..2535e04
--- /dev/null
+++ b/client/src/components/SearchBar.tsx
@@ -0,0 +1,61 @@
+import { ChangeEvent, FormEventHandler, ReactElement } from 'react';
+import styled from 'styled-components';
+
+import Search from '@images/Search.svg';
+
+import Image from '@components/Image';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { lightGray } = PRIMARY_COLORS;
+
+const InputWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ margin: 5px 0;
+ padding: 3px 0;
+
+ background: white;
+ border: 1px solid ${lightGray};
+ border-radius: 5px;
+
+ img {
+ margin: 0 5px;
+ }
+ p {
+ padding: 5px;
+ }
+`;
+
+const SearchBar = ({
+ inputValue,
+ onInput,
+ onKeyDown,
+ onChange,
+}: {
+ inputValue: string;
+ onInput: FormEventHandler;
+ onKeyDown: Function;
+ onChange: (e: ChangeEvent) => void;
+}): ReactElement => {
+ return (
+
+
+ onKeyDown(e)}
+ onChange={onChange}
+ />
+
+ );
+};
+
+export default SearchBar;
diff --git a/client/src/components/SearchListContent.tsx b/client/src/components/SearchListContent.tsx
new file mode 100644
index 0000000..63b18c7
--- /dev/null
+++ b/client/src/components/SearchListContent.tsx
@@ -0,0 +1,45 @@
+import { PlainTodo } from '@todo/todo.type';
+import { ReactElement, useMemo } from 'react';
+import styled from 'styled-components';
+
+import Image from '@components/Image';
+import { getyyyymmddDateFormat } from '@util/Common';
+import Search from '@images/Search.svg';
+
+const SearchTitleWrapper = styled.div`
+ flex-grow: 2;
+`;
+const ListWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ img {
+ margin: 0 5px;
+ }
+ p {
+ padding: 5px;
+ }
+`;
+
+const ListText = styled.p`
+ color: gray;
+ font-size: '15px';
+`;
+
+const SearchListContent = ({ todo }: { todo: PlainTodo }): ReactElement => {
+ const decorateTextDoneTodo = todo.state === 'DONE' ? 'line-through' : '';
+ const ListContentElem = useMemo(() => {
+ return (
+
+
+
+ {todo.title}
+
+ 마감날짜 {getyyyymmddDateFormat(todo.until, '.')}
+
+ );
+ }, [todo]);
+ return <>{ListContentElem}>;
+};
+
+export default SearchListContent;
diff --git a/client/src/components/StartPauseButton.tsx b/client/src/components/StartPauseButton.tsx
new file mode 100644
index 0000000..dd26078
--- /dev/null
+++ b/client/src/components/StartPauseButton.tsx
@@ -0,0 +1,48 @@
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { memo, ReactElement } from 'react';
+import { toast } from 'react-toastify';
+
+import Button from '@components/Button';
+import Pause from '@images/Pause';
+import Start from '@images/Start';
+import { elapsedTimeAtom, isOnProgress, setTimerAtom, todoList } from '@util/GlobalState';
+
+interface ImageButtonStyle {
+ fill?: string;
+ stroke?: string;
+ width?: string;
+ height?: string;
+}
+
+const StartPauseButton = (imageButtonStyle: ImageButtonStyle): ReactElement => {
+ const [progressState] = useAtom(isOnProgress);
+ const [todoListAtom, setTodoListAtom] = useAtom(todoList);
+ const elapsedTime = useAtomValue(elapsedTimeAtom);
+ const setTimer = useSetAtom(setTimerAtom);
+
+ const startPause = (): void => {
+ setTimer();
+
+ if (progressState !== 'working') {
+ return;
+ }
+
+ todoListAtom
+ .updateElapsedTime(elapsedTime)
+ .then((newTodoList) => {
+ setTodoListAtom(newTodoList);
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ };
+
+ return (
+ : }
+ onClick={startPause}
+ />
+ );
+};
+
+export default memo(StartPauseButton);
diff --git a/client/src/components/Text.tsx b/client/src/components/Text.tsx
index 963b688..e22b2ec 100644
--- a/client/src/components/Text.tsx
+++ b/client/src/components/Text.tsx
@@ -1,4 +1,4 @@
-import { FC, memo } from 'react';
+import { FC, memo, ReactNode } from 'react';
import styled from 'styled-components';
interface StyleProps {
@@ -8,6 +8,7 @@ interface StyleProps {
fontFamily?: string;
margin?: string;
textAlign?: string;
+ children?: ReactNode;
}
interface Props extends StyleProps {
@@ -23,8 +24,13 @@ const StyledText = styled.p`
text-align: ${({ textAlign }) => textAlign};
`;
-const Text: FC = ({ text, ...props }) => {
- return {text};
+const Text: FC = ({ text, children, ...props }) => {
+ return (
+
+ {text}
+ {children}
+
+ );
};
export default memo(Text);
diff --git a/client/src/components/ToggleButton.tsx b/client/src/components/ToggleButton.tsx
new file mode 100644
index 0000000..1756f09
--- /dev/null
+++ b/client/src/components/ToggleButton.tsx
@@ -0,0 +1,50 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { gray, green, white } = PRIMARY_COLORS;
+
+const Wrapper = styled.button<{ size: number; color?: string; activeColor?: string; isActive: boolean }>`
+ height: ${(props) => props.size}px;
+ width: ${(props) => props.size * 2}px;
+ box-sizing: content-box;
+ padding: 2px;
+ border-radius: ${(props) => props.size}px;
+ border: 3px solid ${(props) => props.color ?? gray};
+ background-color: ${(props) => (props.isActive ? props.activeColor ?? green : white)}7F;
+ box-shadow: inset 0px 4px 4px rgba(0, 0, 0, 0.25);
+ display: flex;
+ align-items: center;
+ transition-duration: 0.3s;
+`;
+
+const Handle = styled.div<{ size: number; isActive: boolean }>`
+ height: calc(100%);
+ aspect-ratio: 1;
+ border-radius: ${(props) => props.size / 2}px;
+ background-color: ${(props) => props.color ?? gray};
+ transform: translateX(${(props) => (props.isActive ? '100%' : '0%')});
+ transition-duration: 0.3s;
+`;
+
+const ToggleButton = ({
+ size,
+ isActive,
+ onClick,
+ color,
+ activeColor,
+}: {
+ size: number;
+ isActive: boolean;
+ onClick: () => void;
+ color?: string;
+ activeColor?: string;
+}): ReactElement => {
+ return (
+
+
+
+ );
+};
+
+export default ToggleButton;
diff --git a/client/src/components/diagram/DiagramControlPanel.tsx b/client/src/components/diagram/DiagramControlPanel.tsx
new file mode 100644
index 0000000..b7f40e0
--- /dev/null
+++ b/client/src/components/diagram/DiagramControlPanel.tsx
@@ -0,0 +1,35 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+import ToggleButton from '@components/ToggleButton';
+import { PRIMARY_COLORS } from '@util/Constants';
+import Text from '@components/Text';
+
+const { darkestGray, gray } = PRIMARY_COLORS;
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 100%;
+ padding: 5px;
+ display: flex;
+ justify-content: end;
+ border-bottom: 2px solid ${darkestGray};
+ align-items: center;
+ gap: 5px;
+`;
+const DiagramControlPanel = ({ isActive, onClick }: { isActive: boolean; onClick: () => void }): ReactElement => {
+ return (
+
+
+
+
+ );
+};
+
+export default DiagramControlPanel;
diff --git a/client/src/components/diagram/ErrorBubble.tsx b/client/src/components/diagram/ErrorBubble.tsx
new file mode 100644
index 0000000..3bf6417
--- /dev/null
+++ b/client/src/components/diagram/ErrorBubble.tsx
@@ -0,0 +1,24 @@
+import Bubble from '@components/Bubble';
+import { ReactElement } from 'react';
+import Text from '@components/Text';
+import Image from '@components/Image';
+import Error from '@images/Error.svg';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { red } = PRIMARY_COLORS;
+
+const ErrorBubble = (): ReactElement => {
+ return (
+
+
+
+
+ );
+};
+
+export default ErrorBubble;
diff --git a/client/src/components/diagram/NewTodoVertex.tsx b/client/src/components/diagram/NewTodoVertex.tsx
new file mode 100644
index 0000000..a3d12ff
--- /dev/null
+++ b/client/src/components/diagram/NewTodoVertex.tsx
@@ -0,0 +1,80 @@
+import { ReactElement, memo } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+import { getPathValue, BLOCK } from '@util/diagram.util';
+
+const { gray, blue } = PRIMARY_COLORS;
+
+const Wrapper = styled.div`
+ position: absolute;
+ width: 0;
+ height: 0;
+ transform: translate(var(--x), var(--y));
+ pointer-events: none;
+ svg {
+ pointer-events: none;
+ path {
+ pointer-events: none;
+ cursor: pointer;
+ }
+ }
+`;
+
+const Cursor = styled.div`
+ position: absolute;
+ width: 0;
+ height: 0;
+ transform: translate(var(--x), var(--y));
+ &::after {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+ transform: translate(-10px, -5px);
+ border-top: 20px solid ${gray};
+ border-right: 10px solid transparent;
+ border-bottom: 0px solid transparent;
+ border-left: 10px solid transparent;
+ }
+`;
+
+interface VertexProps {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}
+
+const NewTodoVertex = ({ x1, y1, x2, y2 }: VertexProps): ReactElement => {
+ const x = x1 + BLOCK.x / 2;
+ const y = y1 + BLOCK.y;
+ const { path, width, height, translateX, translateY } = getPathValue(x, y, x2, y2);
+ const style = {
+ '--x': `${x}px`,
+ '--y': `${y}px`,
+ };
+ const cursorStyle = {
+ '--x': `${x2}px`,
+ '--y': `${y2}px`,
+ };
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default memo(NewTodoVertex);
diff --git a/client/src/components/diagram/PopUp.tsx b/client/src/components/diagram/PopUp.tsx
new file mode 100644
index 0000000..2d367d2
--- /dev/null
+++ b/client/src/components/diagram/PopUp.tsx
@@ -0,0 +1,23 @@
+import { ReactElement, memo, ReactNode } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { lightGray } = PRIMARY_COLORS;
+
+const Wrapper = styled.div`
+ position: fixed;
+ width: max-content;
+ display: flex;
+ padding: 5px;
+ border-radius: 10px;
+ background-color: ${lightGray};
+ filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25));
+ transform: translate(var(--x), var(--y));
+`;
+
+const PopUp = ({ x, y, children }: { x: number; y: number; children: ReactNode }): ReactElement => {
+ const style = { '--x': `${x}px`, '--y': `${y}px` };
+ return {children};
+};
+
+export default memo(PopUp);
diff --git a/client/src/components/diagram/TodoBlock.tsx b/client/src/components/diagram/TodoBlock.tsx
new file mode 100644
index 0000000..d4931db
--- /dev/null
+++ b/client/src/components/diagram/TodoBlock.tsx
@@ -0,0 +1,119 @@
+import { ReactElement, memo } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+import { Todo } from '@todo/todo';
+import Text from '@components/Text';
+import Button from '@components/Button';
+import Image from '@components/Image';
+import { getCheckTodoStateHandler, getTodoStateIcon } from '@util/todos.util';
+import { useAtom } from 'jotai';
+import { todoList as todoListAtom } from '@util/GlobalState';
+
+const { gray, lightestGray, black } = PRIMARY_COLORS;
+
+const DAY = 1000 * 60 * 60 * 24;
+
+const Wrapper = styled.div`
+ position: absolute;
+ width: max-content;
+ height: max-content;
+ padding: 15px 20px 15px 15px;
+ border-radius: 5px;
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
+ background-color: ${lightestGray};
+ transform: translate(var(--x), var(--y));
+ cursor: pointer;
+ transition: outline 0.3s, transform 1s;
+ outline: 6px solid transparent;
+ box-sizing: border-box;
+ &:hover {
+ outline: 3px solid ${gray};
+ }
+`;
+
+const UpperRow = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 5px;
+`;
+
+const LowerRow = styled(Text)`
+ margin-top: 6px;
+ justify-self: right;
+ text-align: right;
+ color: ${gray};
+ text-overflow: ellipsis;
+`;
+
+const Title = styled(Text)`
+ margin-left: 10px;
+ width: 160px;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+`;
+
+const importanceToString = (importance: number): string => {
+ return +importance === 3 ? '긴급' : +importance === 2 ? '보통' : '여유';
+};
+
+const remainingDayToString = (until: Date): string => {
+ const today = Math.floor(new Date().getTime() / DAY);
+ const untilDate = Math.floor(until.getTime() / DAY);
+ const remaining = today - untilDate;
+ return remaining <= 0 ? `D-${-remaining}` : `D+${remaining}`;
+};
+
+const TodoBlock = ({
+ todo,
+ x,
+ y,
+ id,
+ getOnClick,
+}: {
+ todo: Todo;
+ x: number;
+ y: number;
+ id: string;
+ getOnClick: (
+ type: 'Todo' | 'Vertex' | 'None',
+ id: string,
+ targetPos: { x: number; y: number },
+ ) => (event: React.MouseEvent) => void;
+}): ReactElement => {
+ const [todoList, setTodoList] = useAtom(todoListAtom);
+ const style = {
+ '--x': `${x}px`,
+ '--y': `${y}px`,
+ };
+ return (
+
+
+ }
+ onClick={getCheckTodoStateHandler(todo.toPlain(), todoList, setTodoList)}
+ />
+
+
+
+
+ );
+};
+
+export default memo(TodoBlock);
diff --git a/client/src/components/diagram/TodoBlockPopUp.tsx b/client/src/components/diagram/TodoBlockPopUp.tsx
new file mode 100644
index 0000000..c1bd560
--- /dev/null
+++ b/client/src/components/diagram/TodoBlockPopUp.tsx
@@ -0,0 +1,57 @@
+import { ReactElement, memo } from 'react';
+import PopUp from '@components/diagram/PopUp';
+import Button from '@components/Button';
+import Update from '@images/Update.svg';
+import Path from '@images/Path.svg';
+import Delete from '@images/Delete.svg';
+import { todoList as todoListAtom } from '@util/GlobalState';
+import { useAtom } from 'jotai';
+import { toast } from 'react-toastify';
+import { NewVertexData } from '@container/diagram/Diagram';
+
+const TodoBlockPopUp = ({
+ id,
+ x,
+ y,
+ targetPos,
+ getOnNewVertexClick,
+ setHasEditModal,
+ setEditTargetId,
+}: {
+ id: string;
+ x: number;
+ y: number;
+ targetPos: { x: number; y: number };
+ getOnNewVertexClick: ({ from, x1, y1 }: NewVertexData) => (event: React.MouseEvent) => void;
+ setHasEditModal: React.Dispatch>;
+ setEditTargetId: React.Dispatch>;
+}): ReactElement => {
+ const [todoList, setTodoList] = useAtom(todoListAtom);
+ const onNewVertexClick = getOnNewVertexClick({ from: id, x1: targetPos.x, y1: targetPos.y, x2: NaN, y2: NaN });
+ return (
+
+ }
+ onClick={() => {
+ setHasEditModal(true);
+ setEditTargetId(id);
+ }}
+ />
+ } onClick={onNewVertexClick} />
+ }
+ onClick={() => {
+ todoList
+ .remove(id)
+ .then((newTodoList) => {
+ setTodoList(newTodoList);
+ toast.success('Todo가 성공적으로 삭제되었습니다.');
+ })
+ .catch((err) => toast.error(err));
+ }}
+ />
+
+ );
+};
+
+export default memo(TodoBlockPopUp);
diff --git a/client/src/components/diagram/TodoVertex.tsx b/client/src/components/diagram/TodoVertex.tsx
new file mode 100644
index 0000000..687e2d4
--- /dev/null
+++ b/client/src/components/diagram/TodoVertex.tsx
@@ -0,0 +1,154 @@
+import { ReactElement, useRef, useState, memo, useCallback } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+import WarningBubble from './WarningBubble';
+import ErrorBubble from './ErrorBubble';
+import { getPathValue } from '@util/diagram.util';
+
+const { yellow, red, gray } = PRIMARY_COLORS;
+
+const Wrapper = styled.div`
+ position: absolute;
+ width: 0;
+ height: 0;
+ transform: translate(var(--x), var(--y));
+ transition: transform 1s;
+ pointer-events: none;
+ svg {
+ transition: transform 1s, width 1s, height 1s;
+ pointer-events: none;
+ path {
+ pointer-events: stroke;
+ transition: stroke-width 0.3s, d 1s;
+ cursor: pointer;
+ }
+ }
+`;
+
+const getColor = (type: 'NORMAL' | 'WARNING' | 'ERROR'): string => {
+ return type === 'NORMAL' ? gray : type === 'WARNING' ? yellow : red;
+};
+
+interface VertexProps {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ id: string;
+ type: 'NORMAL' | 'WARNING' | 'ERROR';
+ isHovered: boolean;
+ onMouseEnter: (event: React.MouseEvent) => void;
+ onMouseLeave: (event: React.MouseEvent) => void;
+ getOnMouseMove: (isHovered: boolean) => (event: React.MouseEvent) => void;
+ getOnClick: (
+ type: 'Todo' | 'Vertex' | 'None',
+ id: string,
+ targetPos: { x: number; y: number },
+ ) => (event: React.MouseEvent) => void;
+}
+
+const Vertex = ({
+ x1,
+ y1,
+ x2,
+ y2,
+ id,
+ type,
+ isHovered,
+ onMouseEnter,
+ onMouseLeave,
+ getOnMouseMove,
+ getOnClick,
+}: VertexProps): ReactElement => {
+ const { path, width, height, translateX, translateY } = getPathValue(x1, y1, x2, y2);
+ return (
+
+ );
+};
+
+const MemoVertex = memo(Vertex);
+
+const TodoVertex = ({
+ x1,
+ y1,
+ x2,
+ y2,
+ type,
+ id,
+ getOnClick,
+}: {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ id: string;
+ type: 'NORMAL' | 'WARNING' | 'ERROR';
+ getOnClick: (
+ type: 'Todo' | 'Vertex' | 'None',
+ id: string,
+ targetPos: { x: number; y: number },
+ ) => (event: React.MouseEvent) => void;
+}): ReactElement => {
+ const style = {
+ '--x': `${x1}px`,
+ '--y': `${y1}px`,
+ };
+ const [isHovered, setIsHovered] = useState(false);
+ const [mousePos, setMousePos] = useState<{ x: number; y: number }>({ x: NaN, y: NaN });
+ const domRef = useRef(null);
+ const onMouseLeave = useCallback((event: React.MouseEvent): void => {
+ setIsHovered(() => false);
+ }, []);
+ const onMouseEnter = useCallback((event: React.MouseEvent): void => {
+ setMousePos(() => ({
+ x: event.clientX - (domRef.current?.getBoundingClientRect().left as number),
+ y: event.clientY - (domRef.current?.getBoundingClientRect().top as number),
+ }));
+ setIsHovered(() => true);
+ }, []);
+ const getOnMouseMove = useCallback((isHovered: boolean) => {
+ return (event: React.MouseEvent): void => {
+ if (isHovered && type !== 'NORMAL') {
+ setMousePos(() => ({
+ x: event.clientX - (domRef.current?.getBoundingClientRect().left as number),
+ y: event.clientY - (domRef.current?.getBoundingClientRect().top as number),
+ }));
+ }
+ };
+ }, []);
+
+ return (
+
+
+ {isHovered && (
+
+ {type === 'WARNING' ? : type === 'ERROR' ? : ''}
+
+ )}
+
+ );
+};
+
+export default memo(TodoVertex);
diff --git a/client/src/components/diagram/TodoVertexPopUp.tsx b/client/src/components/diagram/TodoVertexPopUp.tsx
new file mode 100644
index 0000000..e404854
--- /dev/null
+++ b/client/src/components/diagram/TodoVertexPopUp.tsx
@@ -0,0 +1,36 @@
+import { ReactElement, memo } from 'react';
+import PopUp from '@components/diagram/PopUp';
+import Button from '@components/Button';
+import Delete from '@images/Delete.svg';
+import { todoList as todoListAtom } from '@util/GlobalState';
+import { useAtom } from 'jotai';
+import { toast } from 'react-toastify';
+
+const TodoVertexPopUp = ({ id, x, y }: { id: string; x: number; y: number }): ReactElement => {
+ const [todoList, setTodoList] = useAtom(todoListAtom);
+ const [from, to] = id.split('+');
+ return (
+
+ }
+ onClick={() => {
+ todoList
+ .getTodoById(from)
+ .then(async (prevTodo) => {
+ if (prevTodo === undefined) throw new Error('ERROR: 선후관계 제거 중 찾는 Todo가 존재하지 않습니다.');
+ const next = new Set(prevTodo.next);
+ next.delete(to);
+ return await todoList.edit(from, { next: [...next] });
+ })
+ .then((newTodoList) => {
+ setTodoList(newTodoList);
+ toast.success('Todo 선후관계가 성공적으로 삭제되었습니다.');
+ })
+ .catch((err) => toast.error(err));
+ }}
+ />
+
+ );
+};
+
+export default memo(TodoVertexPopUp);
diff --git a/client/src/components/diagram/WarningBubble.tsx b/client/src/components/diagram/WarningBubble.tsx
new file mode 100644
index 0000000..32384e5
--- /dev/null
+++ b/client/src/components/diagram/WarningBubble.tsx
@@ -0,0 +1,24 @@
+import Bubble from '@components/Bubble';
+import { ReactElement } from 'react';
+import Text from '@components/Text';
+import Image from '@components/Image';
+import Warning from '@images/Warning.svg';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { yellow } = PRIMARY_COLORS;
+
+const WarningBubble = (): ReactElement => {
+ return (
+
+
+
+
+ );
+};
+
+export default WarningBubble;
diff --git a/client/src/components/main/PostponeBox.tsx b/client/src/components/main/PostponeBox.tsx
index a90feaf..a7510d0 100644
--- a/client/src/components/main/PostponeBox.tsx
+++ b/client/src/components/main/PostponeBox.tsx
@@ -1,19 +1,25 @@
import { useAtom } from 'jotai';
-import { memo, ReactElement } from 'react';
+import { memo, ReactElement, useEffect } from 'react';
import styled from 'styled-components';
-import Text from '../Text';
-import Button from '../Button';
+import Text from '@components/Text.js';
+import Button from '@components/Button.js';
-import { ACTIVE_TODO_STATE, PRIMARY_COLORS } from '@util/Constants';
-import { isOnProgress } from '@util/GlobalState';
+import { PRIMARY_COLORS } from '@util/Constants';
+import { postponeOptionsAtom, asyncActiveTodo } from '@util/GlobalState';
-const { red, white } = PRIMARY_COLORS;
+import usePostpone from '@hooks/usePostpone.js';
-const StyledPostponeBox = styled.div`
+const { red, white, darkGray } = PRIMARY_COLORS;
+
+interface Props {
+ isBottom: boolean;
+}
+
+const StyledPostponeBox = styled.div`
display: flex;
flex-direction: column;
- background-color: ${red};
+ background-color: ${(props) => (props.isBottom ? darkGray : red)};
line-height: 25px;
letter-spacing: 0em;
align-items: flex-start;
@@ -25,40 +31,41 @@ const StyledPostponeBox = styled.div`
gap: 20px;
position: absolute;
left: 40px;
- top: 60px;
+ top: ${(props) => (props.isBottom ? '' : '60px')};
+ bottom: ${(props) => (props.isBottom ? '100%' : '')};
+ transform: ${(props) => (props.isBottom ? 'translateY(-3px)' : '')};
`;
-interface PostponeProps {
- setPostpone: Function;
- postponeOptions: string[];
- time: number;
- setTime: Function;
- handleOnToggle: Function;
-}
-
-const PostponeBox = (props: PostponeProps): ReactElement => {
- const { setPostpone, postponeOptions, time, setTime, handleOnToggle } = props;
- const [progressState] = useAtom(isOnProgress);
+const PostponeBox = ({ isBottom }: { isBottom: boolean }): ReactElement => {
+ const [postponeOptions, setPostponeOptions] = useAtom(postponeOptionsAtom);
+ const [activeTodo] = useAtom(asyncActiveTodo);
+ const [setPostpone] = usePostpone();
- const handlePosponeClicked = (text: string): void => {
- setPostpone(time, text);
- setTime(0);
+ useEffect(() => {
+ setPostponeOptions();
+ }, [activeTodo]);
- if (progressState === ACTIVE_TODO_STATE.working) {
- handleOnToggle();
- }
+ const handlePosponeClicked = (e: React.MouseEvent): void => {
+ setPostpone((e.target as Element).innerHTML);
};
return (
-
+
{postponeOptions.map((text: string): ReactElement => {
return (
}
- onClick={() => {
- handlePosponeClicked(text);
- }}
+ context={
+
+ }
+ onClick={handlePosponeClicked}
/>
);
})}
diff --git a/client/src/components/main/TodoInteractionButton.tsx b/client/src/components/main/TodoInteractionButton.tsx
index 135fde7..a2a1366 100644
--- a/client/src/components/main/TodoInteractionButton.tsx
+++ b/client/src/components/main/TodoInteractionButton.tsx
@@ -1,54 +1,41 @@
-import { useAtom } from 'jotai';
-import { ReactElement, useMemo, memo } from 'react';
-import styled from 'styled-components';
-
-import Done from '../../images/Done.svg';
-import Postpone from '../../images/Postpone.svg';
-import Button from '../Button';
-import Image from '../Image';
-
-import { postponeClicked, isOnProgress } from '@util/GlobalState.js';
-import { ACTIVE_TODO_STATE } from '@util/Constants';
-
-import useElapsedTime from '../../hooks/useElapsedTime.js';
-import useDone from '../../hooks/useDone.js';
-
-const ButtonWrapper = styled.div`
- display: flex;
- gap: 20px;
-`;
-interface ButtonConfig {
- src: 'string';
-}
-interface ButtonProps {
- buttonConfig: ButtonConfig;
- handleOnToggle: Function;
+import { useAtom, useAtomValue } from 'jotai';
+import { ReactElement, memo, useEffect } from 'react';
+
+import Button from '@components/Button';
+
+import Postpone from '@images/Postpone';
+import Done from '@images/Done';
+
+import { asyncActiveTodo, elapsedTimeAtom, postponeClicked } from '@util/GlobalState.js';
+
+import useDone from '@hooks/useDone.js';
+import StartPauseButton from '@components/StartPauseButton';
+
+interface ImageButtonStyle {
+ fill?: string;
+ stroke?: string;
+ width?: string;
+ height?: string;
}
-const TodoInteractionButton = ({ buttonConfig, handleOnToggle }: ButtonProps): ReactElement => {
+const TodoInteractionButton = (imageButtonStyle: ImageButtonStyle): ReactElement => {
const [isPostpone, setIsPostpone] = useAtom(postponeClicked);
- const [progressState] = useAtom(isOnProgress);
- const [, , , time, setTime] = useElapsedTime();
const [setDone] = useDone();
+ const [elapsedTime, setElapsedTime] = useAtom(elapsedTimeAtom);
+ const activeTodo = useAtomValue(asyncActiveTodo);
- const startPauseButton = useMemo(() => {
- return } onClick={() => handleOnToggle()} />;
- }, [buttonConfig.src]);
-
- const handleDoneClicked = (): void => {
- setDone(time);
- setTime(0);
- if (progressState === ACTIVE_TODO_STATE.working) {
- handleOnToggle();
+ useEffect(() => {
+ if (activeTodo !== undefined && activeTodo.elapsedTime !== elapsedTime) {
+ setElapsedTime(activeTodo.elapsedTime);
}
- };
+ }, [activeTodo]);
return (
-
- {startPauseButton}
- } onClick={() => setIsPostpone(!isPostpone)} />
- } onClick={handleDoneClicked} />
-
+ <>
+
+ } onClick={() => setIsPostpone(!isPostpone)} />
+ } onClick={setDone} />
+ >
);
};
diff --git a/client/src/components/main/TodoTimeText.tsx b/client/src/components/main/TodoTimeText.tsx
index 6623113..5d43901 100644
--- a/client/src/components/main/TodoTimeText.tsx
+++ b/client/src/components/main/TodoTimeText.tsx
@@ -1,9 +1,13 @@
-import { ReactElement, useMemo } from 'react';
+import { ReactElement, memo } from 'react';
import styled from 'styled-components';
+import { useAtom } from 'jotai';
import Text from '@components/Text';
-import useElapsedTime from '../../hooks/useElapsedTime';
+import ElapsedTimeText from '@components/ElapsedTimeText';
+
import { getTodoUntilText } from '@util/Common';
+import { asyncActiveTodo } from '@util/GlobalState';
+import { PRIMARY_COLORS } from '@util/Constants';
const TextWrapper = styled.div`
display: flex;
@@ -12,19 +16,16 @@ const TextWrapper = styled.div`
text-align: right;
`;
-const TodoTimeText = ({ until }: { until: string }): ReactElement => {
- const [displayTime] = useElapsedTime();
-
- const todoUntilText = useMemo(() => {
- return getTodoUntilText(until);
- }, [until]);
+const { darkGray } = PRIMARY_COLORS;
+const TodoTimeText = (): ReactElement => {
+ const [activeTodo] = useAtom(asyncActiveTodo);
return (
-
-
+
+
);
};
-export default TodoTimeText;
+export default memo(TodoTimeText);
diff --git a/client/src/components/todos/BlankTableInform.tsx b/client/src/components/todos/BlankTableInform.tsx
new file mode 100644
index 0000000..7418e1f
--- /dev/null
+++ b/client/src/components/todos/BlankTableInform.tsx
@@ -0,0 +1,18 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+
+const BlankTableWrapper = styled.div`
+ text-align: center;
+ margin: 10%;
+`;
+
+const BlankTableInform = (): ReactElement => {
+ return (
+
+ Todo가 없습니다!
+ Todo를 추가해보는 건 어떨까요?
+
+ );
+};
+
+export default BlankTableInform;
diff --git a/client/src/components/todos/FilterBox.tsx b/client/src/components/todos/FilterBox.tsx
new file mode 100644
index 0000000..6d3d13c
--- /dev/null
+++ b/client/src/components/todos/FilterBox.tsx
@@ -0,0 +1,92 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+
+import Text from '../Text';
+import Button from '../Button';
+
+import { PRIMARY_COLORS } from '@util/Constants';
+import { FilterType } from '@util/todos.util';
+
+const { lightGray, gray, blue } = PRIMARY_COLORS;
+
+const StyledFilterBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ background-color: ${lightGray};
+ line-height: 25px;
+ letter-spacing: 0em;
+ align-items: flex-start;
+ justify-content: center;
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
+ border-radius: 10px;
+ width: max-content;
+ padding: 20px;
+ gap: 20px;
+ position: absolute;
+ left: 50%;
+ z-index: 5;
+ transform: translateX(-50%);
+`;
+
+const Overlay = styled.div`
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 3;
+ background-color: transparent;
+`;
+
+interface FilterProps {
+ filter: Set;
+ setFilter: React.Dispatch>>;
+ setFilterDropDown: React.Dispatch>;
+}
+
+const filterOptions: Array<{ text: string; state: 'DONE' | 'READY' | 'WAIT' }> = [
+ { text: '작업 가능', state: 'READY' },
+ { text: '완료', state: 'DONE' },
+ { text: '대기 중', state: 'WAIT' },
+];
+
+const FilterBox = ({ filter, setFilter, setFilterDropDown }: FilterProps): ReactElement => {
+ return (
+ <>
+ {
+ setFilterDropDown(false);
+ }}
+ />
+
+ {filterOptions.map(({ text, state }: { text: string; state: 'DONE' | 'READY' | 'WAIT' }): ReactElement => {
+ return (
+
+ }
+ onClick={() => {
+ setFilter((prev) => {
+ const newState = new Set([...prev]);
+ if (newState.has(state)) newState.delete(state);
+ else newState.add(state);
+ return newState;
+ });
+ setFilterDropDown(false);
+ }}
+ />
+ );
+ })}
+
+ >
+ );
+};
+
+export default FilterBox;
diff --git a/client/src/components/todos/LabeledInput.tsx b/client/src/components/todos/LabeledInput.tsx
index f9016e5..932a3d5 100644
--- a/client/src/components/todos/LabeledInput.tsx
+++ b/client/src/components/todos/LabeledInput.tsx
@@ -1,21 +1,22 @@
-import { ReactElement, useState, memo } from 'react';
-import Text from '@components/Text';
-import { PRIMARY_COLORS, TABLE_MODALS } from '@util/Constants';
-import { getTodayDate } from '@util/Common';
-
+import { ReactElement, useState, memo, ChangeEvent } from 'react';
import styled from 'styled-components';
+
+import { MAX_DATE, PRIMARY_COLORS } from '@util/Constants';
+import { getDateTimeInputFormatString, getTodayDate } from '@util/Common';
+
+import Text from '@components/Text';
import Select from '@components/Select';
-import { toast } from 'react-toastify';
-import { useAtom } from 'jotai';
-import { modalTypeAtom } from '@util/GlobalState';
+import RelatedTodoInput from './RelatedTodoInput';
-const { darkGray, lightGray } = PRIMARY_COLORS;
+const { darkGray, lightGray, red } = PRIMARY_COLORS;
interface InputProps {
label: string;
maxLength: number | string;
type: string;
id: string;
+ placeHolder: string;
+ editingTodoId?: string;
}
const Wrapper = styled.div`
@@ -42,45 +43,86 @@ const Wrapper = styled.div`
width: 100%;
}
}
- > input[type='date'] {
+ > input[type='datetime-local'] {
&:last-child {
width: 30%;
}
}
`;
-const LabeledInput = ({ label, maxLength, type, id }: InputProps): ReactElement => {
+const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1));
+
+const LabeledInput = ({ label, maxLength, type, id, placeHolder, editingTodoId }: InputProps): ReactElement => {
const [input, setInput] = useState('');
- const [dateInput, setDateInput] = useState(getTodayDate());
- const [modalType] = useAtom(modalTypeAtom);
+ const [dateInput, setDateInput] = useState(getDateTimeInputFormatString(tomorrow));
+ const [warning, setWarning] = useState('');
const handleOnChangeText = (e: React.ChangeEvent): void => {
+ setWarning('');
const { value } = e.target;
+ if (value.match(/[.*+?^${}()|[\]\\]/) !== null) {
+ setWarning('특수문자 .*+?^${}()는 제목에 입력할 수 없습니다.');
+ }
if (maxLength === Number.MAX_VALUE || maxLength < 0) {
return setInput(value);
}
if (maxLength > value.length) {
return setInput(value);
}
- toast.error('제목은 50자 이상 입력 불가능합니다.');
+ setWarning('제목은 50자 이상 입력 불가능합니다.');
};
const handleOnChangeDate = (e: React.ChangeEvent): void => {
const { value } = e.target;
- if (modalType === TABLE_MODALS.create && getTodayDate() > value) {
- toast.error('새로 생성하는 Todo는 과거로 설정 불가능합니다.');
+ setWarning('');
+ if (editingTodoId === undefined && getTodayDate() > value) {
+ setWarning('새로 생성하는 할 일는 과거로 설정 불가능합니다.');
}
return setDateInput(value);
};
+ const handleOnChangeSearchKeyword = (e: ChangeEvent): void => {
+ const { value } = e.target;
+ setWarning('');
+ if (value.match(/[.*+?^${}()|[\]\\]/) !== null) {
+ setWarning('특수문자 .*+?^${}()는 검색할 수 없습니다');
+ }
+ };
+
+ const blockUntilDateAtCreateMode = (): string => {
+ if (editingTodoId === undefined) {
+ return getTodayDate();
+ } else return '';
+ };
+
return (
-
- {type === 'text' && }
- {type === 'textarea' && }
+
+
+
+
+ {type === 'text' && (
+
+ )}
+ {type === 'textarea' && }
+ {type === 'search-prev' && (
+
+ )}
+ {type === 'search-next' && (
+
+ )}
{type === 'select' && }
- {type === 'date' && }
+ {type === 'datetime-local' && (
+
+ )}
);
};
diff --git a/client/src/components/todos/RelatedTodoInput.tsx b/client/src/components/todos/RelatedTodoInput.tsx
new file mode 100644
index 0000000..4986961
--- /dev/null
+++ b/client/src/components/todos/RelatedTodoInput.tsx
@@ -0,0 +1,116 @@
+import { ChangeEvent, ReactElement, useEffect, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import { toast } from 'react-toastify';
+import styled from 'styled-components';
+
+import { todoList } from '@util/GlobalState';
+import { PRIMARY_COLORS } from '@util/Constants';
+import { PlainTodo } from '@todo/todo.type';
+
+import Button from '@components/Button';
+import Search from '@components/Search';
+
+import Cancel from '@images/Cancel.svg';
+
+const { lightestGray, blue } = PRIMARY_COLORS;
+
+const RelatedTodoInputList = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+`;
+
+const InputWrapper = styled.div`
+ display: flex;
+ width: fit-content;
+ border-radius: 10px;
+ position: relative;
+ padding-left: 5px;
+ margin: 5px;
+ background-color: ${lightestGray};
+ button {
+ background: none;
+ margin-right: 5px;
+ }
+ input {
+ text-overflow: ellipsis;
+ background: none;
+ }
+`;
+
+const RelatedTodoInput = ({
+ relatedType,
+ editingTodoId,
+ onChange,
+}: {
+ relatedType: string;
+ editingTodoId?: string;
+ onChange: (e: ChangeEvent) => void;
+}): ReactElement => {
+ const todoListAtom = useAtomValue(todoList);
+ const [relatedTodoList, setRelatedTodoList] = useState([]);
+
+ const getTodoListByIdList = async (idList: string[]): Promise => {
+ return await todoListAtom.getTodoByIdList(idList).then((todoList) => todoList);
+ };
+
+ const getRelatedTodoByIdAndType = async (id: string, type: string): Promise => {
+ return await todoListAtom
+ .getTodoById(id)
+ .then((todo) => (todo !== undefined ? (type === 'prev' ? todo.prev : todo.next) : null));
+ };
+
+ useEffect(() => {
+ const getRelatedTodoList = async (): Promise => {
+ if (editingTodoId === undefined) return setRelatedTodoList(() => []);
+
+ const relatedTodoIdList = await getRelatedTodoByIdAndType(editingTodoId, relatedType);
+ const relatedTodoList = relatedTodoIdList !== null ? await getTodoListByIdList(relatedTodoIdList) : [];
+ if (relatedTodoList.length > 0) setRelatedTodoList(() => [...relatedTodoList]);
+ };
+
+ getRelatedTodoList().catch((err) => toast.error(err));
+ }, [editingTodoId]);
+
+ const onClick = (todo: PlainTodo): void => {
+ const isTodoAlreadyexist =
+ relatedTodoList.filter((relatedTodo: PlainTodo) => relatedTodo.id === todo.id).length > 0;
+ isTodoAlreadyexist ? toast.error('😅 이미 존재하는 할 일 입니다') : setRelatedTodoList((prev) => [...prev, todo]);
+ };
+
+ const deleteRelatedToto = (todoId: string): void => {
+ const newRelatedTodoList = relatedTodoList.filter((el: PlainTodo) => el.id !== todoId);
+ setRelatedTodoList(() => [...newRelatedTodoList]);
+ };
+
+ return (
+
+
+
+ {relatedTodoList.map((relatedTodo: PlainTodo) => {
+ if (relatedTodo === undefined) throw new Error('RelatedTodo가 없습니다');
+ return (
+
+
+ }
+ onClick={() => deleteRelatedToto(relatedTodo.id)}
+ />
+
+ );
+ })}
+
+
+ );
+};
+
+export default RelatedTodoInput;
diff --git a/client/src/components/todos/SortBox.tsx b/client/src/components/todos/SortBox.tsx
new file mode 100644
index 0000000..32d5c52
--- /dev/null
+++ b/client/src/components/todos/SortBox.tsx
@@ -0,0 +1,104 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+
+import Text from '../Text';
+import Button from '../Button';
+
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const { lightGray, blue, gray } = PRIMARY_COLORS;
+
+const StyledSortBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ background-color: ${lightGray};
+ line-height: 25px;
+ letter-spacing: 0em;
+ align-items: flex-start;
+ justify-content: center;
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
+ border-radius: 10px;
+ width: max-content;
+ padding: 20px;
+ gap: 20px;
+ position: absolute;
+ left: 50%;
+ z-index: 5;
+ transform: translateX(-50%);
+`;
+
+const Overlay = styled.div`
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 3;
+ background-color: transparent;
+`;
+
+interface SortProps {
+ sort: Map;
+ setSort: React.Dispatch>>;
+ setSortDropDown: React.Dispatch>;
+ type: string;
+}
+
+const filterOptions: Array<{ text: string; direction: 'NONE' | 'ASCEND' | 'DESCEND' }> = [
+ { text: '선택안함', direction: 'NONE' },
+ { text: '오름차순', direction: 'ASCEND' },
+ { text: '내림차순', direction: 'DESCEND' },
+];
+
+const updateSort = (
+ sort: Map,
+ type: string,
+ direction: 'NONE' | 'ASCEND' | 'DESCEND',
+): Map => {
+ const newSort = new Map([...sort]);
+ if (newSort.has(type)) newSort.delete(type);
+ newSort.set(type, direction);
+ return newSort;
+};
+
+const SortBox = ({ type, sort, setSort, setSortDropDown }: SortProps): ReactElement => {
+ return (
+ <>
+
+ {filterOptions.map(
+ ({ text, direction }: { text: string; direction: 'NONE' | 'ASCEND' | 'DESCEND' }): ReactElement => {
+ return (
+
+ }
+ onClick={() => {
+ setSort((prev) => updateSort(prev, type, direction));
+ setSortDropDown('');
+ }}
+ />
+ );
+ },
+ )}
+
+ {
+ setSortDropDown('');
+ }}
+ />
+ >
+ );
+};
+
+export default SortBox;
diff --git a/client/src/components/todos/TableHeader.tsx b/client/src/components/todos/TableHeader.tsx
index 42e2f38..079579d 100644
--- a/client/src/components/todos/TableHeader.tsx
+++ b/client/src/components/todos/TableHeader.tsx
@@ -1,19 +1,151 @@
-import { ReactElement } from 'react';
+import { ReactElement, useState, memo } from 'react';
+import styled from 'styled-components';
+
+import { PRIMARY_COLORS } from '@util/Constants';
+
import Text from '@components/Text';
+import Button from '@components/Button';
+import FilterBox from '@components/todos/FilterBox';
+import SortBox from '@components/todos/SortBox';
+import { FilterType } from '@util/todos.util';
+
+const { lightGray } = PRIMARY_COLORS;
+interface Props {
+ filter: Set;
+ setFilter: React.Dispatch>>;
+ sort: Map;
+ setSort: React.Dispatch>>;
+}
+
+const GridWrapper = styled.div`
+ display: grid;
+ align-items: center;
+ grid-template-columns: 1fr 3fr 1fr 2fr 1fr 2fr 2fr 2fr;
+ border-bottom: 2px solid ${lightGray};
+ text-align: center;
+ position: sticky;
+ top: 0;
+ background-color: white;
+ z-index: 5;
+ p {
+ margin: 10px 0;
+ }
+`;
-const TableHeader = (): ReactElement => {
+const HeaderUnitWrapper = styled.div`
+ position: relative;
+`;
+
+const HeaderButtonWrapper = styled(Button)`
+ position: relative;
+ z-index: 5;
+`;
+
+const TableHeaderUnit = ({
+ type,
+ sort,
+}: {
+ type: string;
+ sort: Map;
+}): ReactElement => {
+ const index = [...sort]
+ .filter((el) => el[1] !== 'NONE')
+ .map((el) => el[0])
+ .reverse()
+ .findIndex((el) => el === type);
+ const color = ['#1D1D1D', '#888888', '#CCCCCC'];
+ const getSortSymbol = (type: string): string => {
+ if (sort.has(type)) {
+ if (sort.get(type) === 'ASCEND') return '▲';
+ if (sort.get(type) === 'DESCEND') return '▼';
+ }
+ return '';
+ };
+ const typeMap = {
+ title: '제목',
+ importance: '중요도',
+ until: '마감일',
+ };
return (
<>
+ {getSortSymbol(type)}
+ {typeMap[type as keyof typeof typeMap]}
+ >
+ );
+};
+
+const TableHeader = ({ filter, setFilter, sort, setSort, ...props }: Props): ReactElement => {
+ const [filterDropdown, setFilterDropdown] = useState(false);
+ const [sortDropdown, setSortDropdown] = useState('');
+
+ return (
+
-
-
-
-
-
-
+
+ {
+ setFilterDropdown(() => false);
+ setSortDropdown((prev) => (prev === 'title' ? '' : 'title'));
+ }}
+ >
+
+
+
+
+ {sortDropdown === 'title' && (
+
+ )}
+
+
+ {
+ setFilterDropdown((prev) => !prev);
+ setSortDropdown(() => '');
+ }}
+ >
+
+
+ {filterDropdown && }
+
+
+ {
+ setFilterDropdown(() => false);
+ setSortDropdown((prev) => (prev === 'until' ? '' : 'until'));
+ }}
+ >
+
+
+
+
+ {sortDropdown === 'until' && (
+
+ )}
+
+
+ {
+ setFilterDropdown(() => false);
+ setSortDropdown((prev) => (prev === 'importance' ? '' : 'importance'));
+ }}
+ >
+
+
+
+
+ {sortDropdown === 'importance' && (
+
+ )}
+
+
+
+
+
+
+
- >
+
);
};
-export default TableHeader;
+export default memo(TableHeader);
diff --git a/client/src/components/todos/TableRow.tsx b/client/src/components/todos/TableRow.tsx
index 03e54d1..34e32c4 100644
--- a/client/src/components/todos/TableRow.tsx
+++ b/client/src/components/todos/TableRow.tsx
@@ -1,16 +1,30 @@
-import { ReactElement, useState, useEffect } from 'react';
-import TableRowHeader from '@components/todos/TableRowHeader';
-import TableRowDetail from '@components/todos/TableRowDetail';
-import { PlainTodo } from '@todo/todo.type';
-import { displayDetailAtom, todoList } from '@util/GlobalState';
+import { ReactElement, useState, useEffect, memo } from 'react';
import { useAtom } from 'jotai';
+import styled from 'styled-components';
import { toast } from 'react-toastify';
+import { PlainTodo } from '@todo/todo.type';
+import { todoList } from '@util/GlobalState';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+import TableRowHeader from '@components/todos/TableRowHeader';
+import TableRowDetail from '@components/todos/TableRowDetail';
+import EditModal from '@container/EditModal';
+
+const { lightGray } = PRIMARY_COLORS;
+
+const RowWrapper = styled.div`
+ div:nth-child(1):hover {
+ background-color: ${lightGray};
+ }
+`;
+
const TableRow = ({ todo }: { todo: PlainTodo }): ReactElement => {
const [todoListAtom] = useAtom(todoList);
- const [displayDetail] = useAtom(displayDetailAtom);
const [prevTodoList, setPrevTodo] = useState([]);
const [nextTodoList, setNextTodo] = useState([]);
+ const [displayDetail, setDisplayDetail] = useState(false);
+ const [hasEditModal, setHasEditModal] = useState(false);
useEffect(() => {
setPrevTodo(() => []);
@@ -35,12 +49,19 @@ const TableRow = ({ todo }: { todo: PlainTodo }): ReactElement => {
return (
<>
-
- {displayDetail === todo.id && (
-
- )}
+
+ setDisplayDetail(!displayDetail)}
+ setHasEditModal={setHasEditModal}
+ />
+ {displayDetail && }
+
+ {hasEditModal && }
>
);
};
-export default TableRow;
+export default memo(TableRow);
diff --git a/client/src/components/todos/TableRowDetail.tsx b/client/src/components/todos/TableRowDetail.tsx
index 1093131..fbea50d 100644
--- a/client/src/components/todos/TableRowDetail.tsx
+++ b/client/src/components/todos/TableRowDetail.tsx
@@ -1,17 +1,59 @@
+import { ReactElement, memo } from 'react';
+import styled from 'styled-components';
+
+import { PRIMARY_COLORS, TABLE_ROW_DETAIL_TYPE } from '@util/Constants';
+import { getElapsedTimeText, isPlainTodo } from '@util/Common';
import { PlainTodo } from '@todo/todo.type';
-import { ReactElement } from 'react';
+
import TodoTitleList from '@components/todos/TodoTitleList';
-import styled from 'styled-components';
+import Text from '@components/Text';
+import { asyncActiveTodo, isOnProgress } from '@util/GlobalState';
+import { useAtomValue } from 'jotai';
+
+const { red } = PRIMARY_COLORS;
const Wrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ border-bottom: 2px solid #e2e2e2;
+`;
+
+const DetailWrapper = styled.div`
text-align: left;
- margin: 10px;
+ flex-basis: fit-content;
+`;
+
+const BlankDiv = styled.div`
+ width: 55px;
`;
const SubTitle = styled.h3`
font-family: 'Noto Sans';
`;
+const TalbleDetailElement = (type: string, info: PlainTodo | PlainTodo[]): ReactElement => {
+ return (
+ <>
+ {TABLE_ROW_DETAIL_TYPE[type as keyof typeof TABLE_ROW_DETAIL_TYPE]}
+ {isPlainTodo(info) ? : }
+ >
+ );
+};
+
+const ElapsedTimeText = (todo: PlainTodo): ReactElement => {
+ const activeTodo = useAtomValue(asyncActiveTodo);
+ const isWorking = useAtomValue(isOnProgress);
+ return (
+ <>
+ {todo.id === activeTodo.id && isWorking === 'working' ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
const TableRowDetail = ({
todo,
prevTodoList,
@@ -22,29 +64,16 @@ const TableRowDetail = ({
nextTodoList: PlainTodo[];
}): ReactElement => {
return (
- <>
-
-
- {todo.content !== '' && (
- <>
- 상세 내용
- {todo.content}
- >
- )}
- {prevTodoList.length > 0 && (
- <>
- 먼저 할일 목록
-
- >
- )}
- {nextTodoList.length > 0 && (
- <>
- 이어서 할일 목록
-
- >
- )}
-
- >
+
+
+
+ 소요시간
+ {ElapsedTimeText(todo)}
+ {todo.content !== '' && TalbleDetailElement('nowTodo', todo)}
+ {prevTodoList.length > 0 && TalbleDetailElement('prevTodoList', prevTodoList)}
+ {nextTodoList.length > 0 && TalbleDetailElement('nextTodoList', nextTodoList)}
+
+
);
};
-export default TableRowDetail;
+export default memo(TableRowDetail);
diff --git a/client/src/components/todos/TableRowHeader.tsx b/client/src/components/todos/TableRowHeader.tsx
index 23c2a6e..47a9941 100644
--- a/client/src/components/todos/TableRowHeader.tsx
+++ b/client/src/components/todos/TableRowHeader.tsx
@@ -1,85 +1,122 @@
-import { ReactElement } from 'react';
+import { ReactElement, MouseEventHandler, CSSProperties, Dispatch, SetStateAction, memo } from 'react';
import styled from 'styled-components';
import { useAtom } from 'jotai';
+import { toast } from 'react-toastify';
-import { TODO_STATE_TEXT, IMPORTANCE_ALPHABET, TABLE_MODALS } from '@util/Constants';
-import { getyyyymmddDateFormat, gethhmmFormat } from '@util/Common';
+import { PlainTodo } from '@todo/todo.type';
+import { PRIMARY_COLORS, TODO_STATE_TEXT, IMPORTANCE_ALPHABET } from '@util/Constants';
+import { copyToClipboard, gethhmmFormat, getyyyymmddDateFormat } from '@util/Common';
+import { todoList } from '@util/GlobalState';
import Button from '@components/Button';
import Image from '@components/Image';
-import Unchecked from '@images/Unchecked.svg';
-import Checked from '@images/Checked.svg';
import Delete from '@images/Delete.svg';
import Update from '@images/Update.svg';
+import Copy from '@images/Copy.svg';
+import { getCheckTodoStateHandler, getListInfoText, getTodoStateIcon } from '@util/todos.util';
-import { PlainTodo } from '@todo/todo.type';
+const { lightGray } = PRIMARY_COLORS;
-import { modalTypeAtom, todoList, editingTodoIdAtom } from '@util/GlobalState';
-import { toast } from 'react-toastify';
+interface HeaderElementData {
+ todo: PlainTodo;
+ prevTodoList: PlainTodo[];
+ nextTodoList: PlainTodo[];
+}
-const CheckWrapper = styled.div`
- input {
- display: none;
- }
- img {
- cursor: pointer;
- }
-`;
+interface HeaderElem {
+ type: string;
+ style: CSSProperties;
+ value: string;
+}
-const TextWrapper = styled.div`
- overflow: hidden;
- white-space: nowrap;
-`;
+interface DetailCssStyle {
+ text: CSSProperties;
+ title: CSSProperties;
+ content: CSSProperties;
+ null: CSSProperties;
+}
-const TitleWrapper = styled(TextWrapper)`
- margin-right: 10px;
- text-overflow: ellipsis;
- text-align: left;
- font-weight: 700;
+const Wrapper = styled.div`
+ display: grid;
+ align-items: center;
+ grid-template-columns: 1fr 3fr 1fr 2fr 1fr 2fr 2fr 2fr;
+ border-bottom: 2px solid ${lightGray};
+ text-align: center;
+ position: sticky;
+ top: 0;
+ background-color: white;
+ p {
+ margin: 10px 0;
+ }
`;
-const ContentWrapper = styled(TextWrapper)`
- margin: 0 10px;
-`;
+const TABLE_ROW_DETAIL_STYLES: DetailCssStyle = {
+ text: { overflow: 'hidden', whiteSpace: 'nowrap' },
+ title: {
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ marginRight: '10px',
+ textOverflow: 'ellipsis',
+ textAlign: 'left',
+ fontWeight: 700,
+ },
+ content: {
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ margin: '0 10px',
+ },
+ null: {},
+};
-const getListInfoText = (list: string[], firstTodoTitle: string | undefined): string => {
- if (firstTodoTitle === undefined) return '-';
- if (list.length > 1) {
- return firstTodoTitle.length > 10
- ? [firstTodoTitle.slice(0, 10), '... 외 ', list.length - 1].join('')
- : [firstTodoTitle, '외', list.length - 1].join(' ');
- } else if (list.length === 1) {
- return firstTodoTitle.length > 12 ? [firstTodoTitle.slice(0, 12), '...'].join('') : firstTodoTitle;
- }
- return '-';
+const createHeaderElementData = ({ todo, prevTodoList, nextTodoList }: HeaderElementData): HeaderElem[] => {
+ return [
+ {
+ type: 'title',
+ style: TABLE_ROW_DETAIL_STYLES.title,
+ value: todo.title,
+ },
+ {
+ type: 'state',
+ style: TABLE_ROW_DETAIL_STYLES.text,
+ value: TODO_STATE_TEXT[todo.state],
+ },
+ {
+ type: 'until',
+ style: TABLE_ROW_DETAIL_STYLES.text,
+ value: `${getyyyymmddDateFormat(todo.until, '.')} ${gethhmmFormat(todo.until)}`,
+ },
+ {
+ type: 'importance',
+ style: TABLE_ROW_DETAIL_STYLES.null,
+ value: IMPORTANCE_ALPHABET[todo.importance],
+ },
+ {
+ type: 'prev',
+ style: TABLE_ROW_DETAIL_STYLES.content,
+ value: getListInfoText(prevTodoList),
+ },
+ {
+ type: 'next',
+ style: TABLE_ROW_DETAIL_STYLES.content,
+ value: getListInfoText(nextTodoList),
+ },
+ ];
};
const TableRowHeader = ({
todo,
- prevTodoTitle,
- nextTodoTitle,
+ prevTodoList,
+ nextTodoList,
+ onClick,
+ setHasEditModal,
}: {
todo: PlainTodo;
- prevTodoTitle: string;
- nextTodoTitle: string;
+ prevTodoList: PlainTodo[];
+ nextTodoList: PlainTodo[];
+ onClick: MouseEventHandler;
+ setHasEditModal: Dispatch>;
}): ReactElement => {
- const [, setModalType] = useAtom(modalTypeAtom);
const [todoListAtom, setTodoListAtom] = useAtom(todoList);
- const [, setEditingTodoId] = useAtom(editingTodoIdAtom);
-
- const checkTodoStateHandler = (): void => {
- // API에서 알고리즘으로 todo state를 배정해주므로 DONE일 때는 임의로 WAIT으로 바꿔 전송 : WAIT/READY 상관없음
-
- let newTodo = {};
- newTodo = { ...todo, state: todo.state === 'DONE' ? 'WAIT' : 'DONE' };
- todoListAtom
- .edit(todo.id, newTodo)
- .then((newTodoList) => {
- setTodoListAtom(newTodoList);
- toast.success('완료되었습니다.');
- })
- .catch((err) => toast.error(err));
- };
const handleOnDelete = (todoId: string): void => {
todoListAtom
@@ -92,40 +129,47 @@ const TableRowHeader = ({
});
};
+ const tableRowHeaderElemList = createHeaderElementData({ todo, prevTodoList, nextTodoList });
+
return (
- <>
-
- {todo.state === 'DONE' ? (
- } onClick={checkTodoStateHandler} />
- ) : (
- } onClick={checkTodoStateHandler} />
- )}
-
- {todo.title}
- {TODO_STATE_TEXT[todo.state]}
-
- {getyyyymmddDateFormat(todo.until, '.')} {gethhmmFormat(todo.until)}
-
- {IMPORTANCE_ALPHABET[todo.importance]}
- {getListInfoText(todo.prev, prevTodoTitle)}
- {getListInfoText(todo.next, nextTodoTitle)}
-
+
+ }
+ onClick={getCheckTodoStateHandler(todo, todoListAtom, setTodoListAtom)}
+ />
+ {tableRowHeaderElemList.map((headerElem) => {
+ return (
+
+ {headerElem.value}
+
+ );
+ })}
+ {
+ e.stopPropagation();
+ }}
+ >
}
- onClick={(e) => {
- setEditingTodoId(todo.id);
- setModalType(TABLE_MODALS.update);
+ context={
}
+ onClick={() => {
+ setHasEditModal(true);
}}
/>
}
- onClick={(e) => {
+ context={
}
+ onClick={() => {
handleOnDelete(todo.id);
}}
/>
+
}
+ onClick={() => {
+ copyToClipboard(todo.title);
+ }}
+ />
- >
+
);
};
-export default TableRowHeader;
+export default memo(TableRowHeader);
diff --git a/client/src/components/todos/TodoTitleList.tsx b/client/src/components/todos/TodoTitleList.tsx
index 30be7d3..2d231fa 100644
--- a/client/src/components/todos/TodoTitleList.tsx
+++ b/client/src/components/todos/TodoTitleList.tsx
@@ -1,11 +1,15 @@
import { ReactElement } from 'react';
import { PlainTodo } from '@todo/todo.type';
-const TodoTitleList = ({ list, prevId }: { list: PlainTodo[]; prevId: string }): ReactElement => {
+const TodoTitleList = ({ list }: { list: PlainTodo[] }): ReactElement => {
return (
{list.map((todo: PlainTodo) => {
- return - {todo.title}
;
+ return (
+ -
+ {todo.title}
+
+ );
})}
);
diff --git a/client/src/components/tutorial/Dots.tsx b/client/src/components/tutorial/Dots.tsx
new file mode 100644
index 0000000..4ee661a
--- /dev/null
+++ b/client/src/components/tutorial/Dots.tsx
@@ -0,0 +1,35 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const Dot = styled.span<{ active: boolean }>`
+ padding: 5px;
+ margin-right: 10px;
+ cursor: none;
+ border-radius: 50%;
+ background: ${(props) => (props.active ? `${PRIMARY_COLORS.lightGray}` : `${PRIMARY_COLORS.darkGray}`)}};
+`;
+
+const Wrapper = styled.div`
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ bottom: 25px;
+ width: 100%;
+`;
+
+interface DotsProps {
+ slides: string[];
+ currentIndex: number;
+}
+
+export const Dots = ({ slides, currentIndex }: DotsProps): ReactElement => {
+ return (
+
+ {slides.map((slide, i) => (
+
+ ))}
+
+ );
+};
diff --git a/client/src/components/tutorial/Icons.tsx b/client/src/components/tutorial/Icons.tsx
new file mode 100644
index 0000000..f1e065d
--- /dev/null
+++ b/client/src/components/tutorial/Icons.tsx
@@ -0,0 +1,54 @@
+import { ComponentProps, ReactElement } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+const Svg = styled.svg`
+ display: inline-block;
+`;
+
+export const PrevIcon = ({
+ icon,
+ fill = PRIMARY_COLORS.darkGray,
+ ...props
+}: ComponentProps
): ReactElement => {
+ return (
+
+ );
+};
+
+export const NextIcon = ({ icon, fill = '#3F3F3F', ...props }: ComponentProps): ReactElement => {
+ return (
+
+ );
+};
+
+export const CancelIcon = ({ icon, fill = '#3F3F3F', ...props }: ComponentProps): ReactElement => {
+ return (
+
+ );
+};
diff --git a/client/src/components/tutorial/ToggleButton.tsx b/client/src/components/tutorial/ToggleButton.tsx
new file mode 100644
index 0000000..15f0fe1
--- /dev/null
+++ b/client/src/components/tutorial/ToggleButton.tsx
@@ -0,0 +1,57 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+interface ButtonStylingProps {
+ isActive: boolean;
+}
+
+const StyledButton = styled.button`
+ position: absolute;
+ inset: 0px;
+ transition: all 200ms ease 0s;
+ border-radius: 22px;
+ cursor: pointer;
+ border: 2px solid ${PRIMARY_COLORS.gray};
+ background: transparent;
+
+ &::before {
+ width: 14px;
+ height: 14px;
+ left: 3px;
+ bottom: 2px;
+ background-color: ${PRIMARY_COLORS.gray};
+ position: absolute;
+ content: '';
+ transition: all 200ms ease 0s;
+ border-radius: 50%;
+ }
+
+ ${({ isActive }) =>
+ isActive &&
+ `
+ background-color: ${PRIMARY_COLORS.blue};
+ &::before {
+ transform: translateX(16px);
+ }
+ `}
+`;
+const Wrapper = styled.div`
+ position: relative;
+ display: inline-block;
+ width: 40px;
+ height: 22px;
+`;
+
+interface ToggleButtonProps {
+ isActive: boolean;
+ toggleActive: () => void;
+}
+
+export const ToggleButton = ({ isActive, toggleActive, ...props }: ToggleButtonProps): ReactElement => {
+ return (
+
+
+
+ );
+};
diff --git a/client/src/components/tutorial/TutorialImage.tsx b/client/src/components/tutorial/TutorialImage.tsx
new file mode 100644
index 0000000..be41588
--- /dev/null
+++ b/client/src/components/tutorial/TutorialImage.tsx
@@ -0,0 +1,95 @@
+import { ReactElement, useEffect, useState, useRef } from 'react';
+import styled from 'styled-components';
+import { Dots } from './Dots';
+
+import { PrevIcon, NextIcon, CancelIcon } from './Icons';
+import { PRIMARY_COLORS } from '@util/Constants';
+import Button from '@components/Button';
+
+import Tutorial1 from '@images/tutorial/tutorial-page-1.svg';
+import Tutorial2 from '@images/tutorial/tutorial-page-2.svg';
+import Tutorial3 from '@images/tutorial/tutorial-page-3.svg';
+import Tutorial5 from '@images/tutorial/tutorial-page-5.svg';
+
+const StyledOverlay = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: ${PRIMARY_COLORS.black};
+ width: 100vw;
+ height: 100vh;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ z-index: 10;
+
+ & > img {
+ width: 80%;
+ height: 80%;
+ }
+`;
+
+const imgSrcArray: string[] = [Tutorial1, Tutorial2, Tutorial3, Tutorial5];
+
+const StyledButton = styled(Button)`
+ background-color: transparent;
+`;
+
+const StyledCancelButton = styled(Button)`
+ position: absolute;
+ background-color: transparent;
+ top: 0px;
+ right: 0px;
+`;
+
+export const TutorialImage = ({
+ isTutorial,
+ setIsOver,
+}: {
+ isTutorial: boolean;
+ setIsOver: React.Dispatch>;
+}): ReactElement => {
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const autoPlayRef = useRef<() => void>();
+
+ useEffect(() => {
+ autoPlayRef.current = nextSlide;
+ });
+
+ useEffect(() => {
+ const play = (): void => {
+ if (autoPlayRef.current !== undefined) autoPlayRef.current();
+ };
+
+ const interval = setInterval(play, 10 * 1000);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, [currentIndex]);
+
+ const prevSlide = (): void => {
+ setCurrentIndex(currentIndex === 0 ? imgSrcArray.length - 1 : currentIndex - 1);
+ };
+
+ const nextSlide = (): void => {
+ setCurrentIndex(currentIndex === imgSrcArray.length - 1 ? 0 : currentIndex + 1);
+ };
+
+ const exit = (): void => {
+ setIsOver(true);
+ };
+ return (
+
+ } onClick={exit} />
+ } onClick={prevSlide} />
+
+ } onClick={nextSlide} />
+
+
+ );
+};
diff --git a/client/src/container/CreateModal.tsx b/client/src/container/CreateModal.tsx
new file mode 100644
index 0000000..8ed2d2d
--- /dev/null
+++ b/client/src/container/CreateModal.tsx
@@ -0,0 +1,43 @@
+import { memo, ReactElement, Dispatch, SetStateAction } from 'react';
+
+import { MODAL_INPUT_LIST, MODAL_LABEL_ID } from '@util/Constants';
+import LabeledInput from '@components/todos/LabeledInput';
+
+import Modal from '@components/Modal';
+import styled from 'styled-components';
+import useModalComplete from '@hooks/useModalComplete';
+
+const InputWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+`;
+
+const MODAL_CREATE = 'create';
+
+const CreateModal = ({ setHasCreateModal }: { setHasCreateModal: Dispatch> }): ReactElement => {
+ const [setComplete] = useModalComplete(MODAL_CREATE);
+
+ return (
+
+
+ {MODAL_INPUT_LIST.map((item) => {
+ const { type, label, maxLength, placeHolder } = item;
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default memo(CreateModal);
diff --git a/client/src/container/EditModal.tsx b/client/src/container/EditModal.tsx
new file mode 100644
index 0000000..e5a9378
--- /dev/null
+++ b/client/src/container/EditModal.tsx
@@ -0,0 +1,90 @@
+import { memo, ReactElement, useRef, Dispatch, SetStateAction, useCallback, useEffect } from 'react';
+import { useAtom } from 'jotai';
+
+import { MODAL_INPUT_LIST, MODAL_LABEL_ID } from '@util/Constants';
+import { todoList } from '@util/GlobalState';
+import { getDateTimeInputFormatString, getModalValues } from '@util/Common';
+
+import LabeledInput from '@components/todos/LabeledInput';
+
+import Modal from '@components/Modal';
+import styled from 'styled-components';
+import useModalComplete from '@hooks/useModalComplete';
+
+interface WrapperProps {
+ ref: any;
+}
+
+const InputWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+`;
+
+const MODAL_EDIT = 'update';
+
+const EditModal = ({
+ setHasEditModal,
+ editingTodoId,
+}: {
+ setHasEditModal: Dispatch>;
+ editingTodoId: string;
+}): ReactElement => {
+ const [todoListAtom] = useAtom(todoList);
+ const editModalWrapper = useRef();
+ const [setComplete] = useModalComplete(MODAL_EDIT);
+
+ const setInitData = useCallback((): void => {
+ todoListAtom
+ .getTodoById(editingTodoId)
+ .then((target) => {
+ if (editModalWrapper.current === undefined || target === undefined) {
+ return;
+ }
+ getModalValues(editModalWrapper.current).forEach((elem) => {
+ if (elem.id === 'until') {
+ return (elem.value = getDateTimeInputFormatString(new Date(target.until)));
+ }
+ if (elem.dataset.label === 'search-input') return;
+
+ elem.value = target[elem.id as keyof typeof target];
+ });
+ })
+ .catch((err) => {
+ throw new Error(err);
+ });
+ }, [editingTodoId]);
+
+ useEffect(() => {
+ setInitData();
+ }, []);
+
+ return (
+
+
+ {MODAL_INPUT_LIST.map((item) => {
+ const { type, label, maxLength, placeHolder } = item;
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default memo(EditModal);
diff --git a/client/src/container/Header.tsx b/client/src/container/Header.tsx
index 883b16c..60f370a 100644
--- a/client/src/container/Header.tsx
+++ b/client/src/container/Header.tsx
@@ -5,7 +5,12 @@ import LongLogo from '@images/LongLogo.svg';
import { Link } from 'react-router-dom';
import Image from '@components/Image';
-import LoginButton from '@components/LoginButton';
+import Button from '@components/Button';
+import Text from '@components/Text';
+import { PRIMARY_COLORS } from '@util/Constants';
+
+import { isTutorialAtom, changeIndexedDBtoMemoryAtom, changeMemorytoIndexedDBAtom } from '@util/GlobalState';
+import { useAtom } from 'jotai';
const Wrapper = styled.div`
display: flex;
@@ -15,16 +20,54 @@ const Wrapper = styled.div`
height: 10vh;
padding: 25px;
font-family: 'Roboto';
- z-index: -10;
`;
const Header = (): ReactElement => {
+ const [isTutorial, setIsTutorial] = useAtom(isTutorialAtom);
+ const [, changeIndexedDBtoMemory] = useAtom(changeIndexedDBtoMemoryAtom);
+ const [, changeMemorytoIndexedDB] = useAtom(changeMemorytoIndexedDBAtom);
+ const startTutorial = (): void => {
+ setIsTutorial(true);
+ changeIndexedDBtoMemory();
+ };
+ const endTutorial = (): void => {
+ setIsTutorial(false);
+ changeMemorytoIndexedDB();
+ };
return (
-
+ {isTutorial ? (
+
+
+ }
+ />
+
+ ) : (
+
+
+ }
+ />
+
+ )}
);
};
diff --git a/client/src/container/Menubar.tsx b/client/src/container/Menubar.tsx
index 795b618..db39d97 100644
--- a/client/src/container/Menubar.tsx
+++ b/client/src/container/Menubar.tsx
@@ -1,12 +1,20 @@
-import { ReactElement } from 'react';
+import { ReactElement, memo } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import Home from '@images/Home.svg';
import Table from '@images/Table.svg';
+
import Image from '@components/Image';
+import Diagram from '@images/Diagram.svg';
+import { isTutorialAtom } from '@util/GlobalState';
+import { useAtomValue } from 'jotai';
const Wrapper = styled.div`
+ position: relative;
+ width: max-content;
+ display: flex;
+ flex-direction: column;
height: 100vh;
background: #fcfcfc;
box-shadow: 2px 0px 4px rgba(0, 0, 0, 0.25);
@@ -14,16 +22,21 @@ const Wrapper = styled.div`
`;
const Menubar = (): ReactElement => {
+ const isTutorial = useAtomValue(isTutorialAtom);
+ const prefix: string = isTutorial ? '/tutorials' : '';
return (
-
-
+
+
+
+
+
-
-
+
+
);
};
-export default Menubar;
+export default memo(Menubar);
diff --git a/client/src/container/TodoController.tsx b/client/src/container/TodoController.tsx
new file mode 100644
index 0000000..ba58fa6
--- /dev/null
+++ b/client/src/container/TodoController.tsx
@@ -0,0 +1,101 @@
+import { ReactElement, useEffect } from 'react';
+import styled from 'styled-components';
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { useLocation } from 'react-router-dom';
+
+import Text from '@components/Text';
+import ElapsedTimeText from '@components/ElapsedTimeText';
+import TodoInteractionButton from '@components/main/TodoInteractionButton';
+
+import { asyncActiveTodo, isMainPageAtom, needTodoControllerAtom, postponeClicked } from '@util/GlobalState';
+import { getTodoUntilText } from '@util/Common';
+import { PRIMARY_COLORS } from '@util/Constants';
+import PostponeBox from '@components/main/PostponeBox';
+
+const { white, darkGray, offWhite } = PRIMARY_COLORS;
+
+interface PropsType {
+ active: boolean;
+}
+const Wrapper = styled.div`
+ display: flex;
+ height: max-content;
+ width: calc(100vw);
+ padding-inline: 15px;
+ padding-block: 0px;
+ border-radius: 10px 10px 0 0;
+ align-items: center;
+ color: ${white};
+ background-color: ${darkGray};
+ justify-content: space-between;
+ position: fixed;
+ bottom: 0;
+ transform: ${(props) => (props.active ? '0vh' : 'translateY(100%)')};
+ transition-property: transform;
+ transition-duration: 1s;
+
+ & > p {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ min-width: 10px;
+ }
+`;
+
+const ButtonWrapper = styled.div`
+ display: flex;
+ gap: 5px;
+`;
+
+const TextWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ white-space: nowrap;
+ width: max-content;
+`;
+
+const imageButtonStyle = {
+ fill: offWhite,
+ stroke: offWhite,
+ width: '45px',
+};
+
+const TodoController = (): ReactElement => {
+ const location = useLocation();
+ const activeTodo = useAtomValue(asyncActiveTodo);
+ const [needTodoController, setNeedTodoController] = useAtom(needTodoControllerAtom);
+ const setIsMainPage = useSetAtom(isMainPageAtom);
+ const isPostpone = useAtomValue(postponeClicked);
+
+ useEffect(() => {
+ setIsMainPage();
+ setNeedTodoController();
+ }, [location]);
+
+ useEffect(() => {
+ setNeedTodoController();
+ }, [activeTodo]);
+
+ return (
+
+
+
+ {isPostpone && needTodoController && }
+
+
+
+
+
+
+
+ );
+};
+
+export default TodoController;
diff --git a/client/src/container/diagram/Diagram.tsx b/client/src/container/diagram/Diagram.tsx
new file mode 100644
index 0000000..cb19f86
--- /dev/null
+++ b/client/src/container/diagram/Diagram.tsx
@@ -0,0 +1,302 @@
+import { ReactElement, useEffect, useState, useRef, useCallback, memo, useMemo } from 'react';
+import { useAtom } from 'jotai';
+import { todoList } from '@util/GlobalState';
+import styled from 'styled-components';
+import { PRIMARY_COLORS } from '@util/Constants';
+import TodoBlock from '@components/diagram/TodoBlock';
+import TodoVertex from '@components/diagram/TodoVertex';
+import TodoBlockPopUp from '@components/diagram/TodoBlockPopUp';
+import TodoVertexPopUp from '@components/diagram/TodoVertexPopUp';
+import NewTodoVertex from '@components/diagram/NewTodoVertex';
+import { toast } from 'react-toastify';
+import { useDiagramAnimation } from '@hooks/useDiagramAnimation';
+import EditModal from '@container/EditModal';
+import CreateModal from '@container/CreateModal';
+import Button from '@components/Button';
+import Image from '@components/Image';
+import Create from '@images/Create.svg';
+
+const { offWhite, green } = PRIMARY_COLORS;
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 0;
+ height: 0;
+ background-color: ${offWhite};
+ transform: translate(var(--offsetX), var(--offsetY));
+`;
+
+const Detector = memo(styled.div`
+ position: absolute;
+ background-color: ${offWhite};
+ width: 100%;
+ height: 100%;
+ background-color: transparent;
+`);
+
+const HorizontalBaseLine = memo(styled.div`
+ position: absolute;
+ height: 0;
+ width: 100%;
+ top: 40px;
+ transform: translateY(var(--offsetY));
+ border-top: 4px dashed ${green};
+ opacity: 0.5;
+`);
+
+const VerticalBaseLine = memo(styled.div`
+ position: absolute;
+ height: 100%;
+ width: 0;
+ left: 110px;
+ transform: translateX(var(--offsetX));
+ border-left: 4px dashed ${green};
+ opacity: 0.5;
+`);
+
+const StyledButton = styled.div`
+ position: fixed;
+ bottom: 10vh;
+ right: 5%;
+ width: 80px;
+ height: 80px;
+`;
+
+interface ClickData {
+ type: 'Todo' | 'Vertex' | 'None';
+ x: number;
+ y: number;
+ id: string;
+ targetPos: { x: number; y: number };
+}
+
+const defaultClickData: ClickData = {
+ type: 'None',
+ x: 0,
+ y: 0,
+ id: '',
+ targetPos: { x: 0, y: 0 },
+};
+
+export interface NewVertexData {
+ from: string;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}
+
+const defaultNewVertexData: NewVertexData = {
+ from: '',
+ x1: NaN,
+ y1: NaN,
+ x2: NaN,
+ y2: NaN,
+};
+
+const TodoBlockWrapper = styled.div<{ aniState: string }>`
+ opacity: ${(props) => (props.aniState === 'idle' ? 1 : 0)};
+ transition: opacity 0.5s;
+`;
+
+const MemoTodoBlockWrapper = memo(TodoBlockWrapper);
+
+const Diagram = ({ showDone }: { showDone: boolean }): ReactElement => {
+ const [todoListAtom, setTodoListAtom] = useAtom(todoList);
+ const { todoBlockData, vertexData } = useDiagramAnimation(todoListAtom, showDone);
+ const [offset, setOffset] = useState<{ x: number; y: number }>({ x: 100, y: 100 });
+ const [clickData, setClickData] = useState(defaultClickData);
+ const [newVertexData, setNewVertexData] = useState(defaultNewVertexData);
+ const [isWheelDown, setIsWheelDown] = useState(false);
+ const [editTargetId, setEditTargetId] = useState('');
+ const [hasEditModal, setHasEditModal] = useState(false);
+ const [hasCreateModal, setHasCreateModal] = useState(false);
+ const domRef = useRef(null);
+
+ useEffect(() => {
+ if (clickData.type === 'Todo' && newVertexData.from !== '') {
+ const from = newVertexData.from;
+ const to = clickData.id;
+ let fromTitle = '';
+ let toTitle = '';
+ todoListAtom
+ .getTodoById(from)
+ .then(async (prevTodo) => {
+ if (prevTodo === undefined) throw new Error('ERROR: 선후관계 제거 중 찾는 Todo가 존재하지 않습니다.');
+ const next = new Set(prevTodo.next);
+ if (next.has(to)) throw new Error('ERROR: 이미 동일한 선후관계가 존재합니다.');
+ next.add(to);
+ fromTitle = prevTodo.title;
+ toTitle = (await todoListAtom.getTodoById(to))?.title as string;
+ return await todoListAtom.edit(from, { next: [...next] });
+ })
+ .then((newTodoList) => {
+ setTodoListAtom(newTodoList);
+ toast.success(`Todo 선후관계가 추가되었습니다. ${fromTitle} → ${toTitle}`);
+ })
+ .then(() => {
+ setNewVertexData(defaultNewVertexData);
+ setClickData(defaultClickData);
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ setNewVertexData(defaultNewVertexData);
+ setClickData(defaultClickData);
+ });
+ }
+ }, [clickData]);
+
+ const diagramStyle = useMemo(
+ () => ({
+ '--offsetX': `${offset.x}px`,
+ '--offsetY': `${offset.y}px`,
+ }),
+ [offset],
+ );
+
+ const horizontalLineStyle = useMemo(
+ () => ({
+ '--offsetY': `${offset.y}px`,
+ }),
+ [offset],
+ );
+
+ const verticalLineStyle = useMemo(
+ () => ({
+ '--offsetX': `${offset.x}px`,
+ }),
+ [offset],
+ );
+
+ const onWheelDown = (event: React.MouseEvent): void => {
+ if (event.button === 1) {
+ setIsWheelDown(true);
+ }
+ };
+
+ const onWheelUp = (event: React.MouseEvent): void => {
+ setIsWheelDown(false);
+ };
+
+ const onWheelLeave = (event: React.MouseEvent): void => {
+ setIsWheelDown(false);
+ };
+
+ const onMouseGrabMove = (event: React.MouseEvent): void => {
+ if (isWheelDown) {
+ setOffset((prev) => ({ x: prev.x + event.movementX, y: prev.y + event.movementY }));
+ }
+ };
+
+ const onMouseNewVertexMove = (event: React.MouseEvent): void => {
+ if (newVertexData.from !== undefined) {
+ setNewVertexData((prev) => ({
+ ...prev,
+ x2: event.clientX - (domRef.current?.getBoundingClientRect().left as number),
+ y2: event.clientY - (domRef.current?.getBoundingClientRect().top as number),
+ }));
+ }
+ };
+
+ const onMouseMove = (event: React.MouseEvent): void => {
+ onMouseGrabMove(event);
+ onMouseNewVertexMove(event);
+ };
+
+ const getOnClick = useCallback(
+ (type: 'Todo' | 'Vertex' | 'None', id: string, targetPos: { x: number; y: number }) => {
+ return (event: React.MouseEvent): void => {
+ setClickData({
+ type,
+ id,
+ x: event.clientX - (domRef.current?.getBoundingClientRect().left as number),
+ y: event.clientY - (domRef.current?.getBoundingClientRect().top as number),
+ targetPos,
+ });
+ event.stopPropagation();
+ };
+ },
+ [],
+ );
+
+ const getOnNewVertexClick = useCallback(({ from, x1, y1 }: NewVertexData) => {
+ return (event: React.MouseEvent): void => {
+ setNewVertexData({
+ from,
+ x1,
+ y1,
+ x2: event.clientX - (domRef.current?.getBoundingClientRect().left as number),
+ y2: event.clientY - (domRef.current?.getBoundingClientRect().top as number),
+ });
+ getOnClick('None', '', { x: NaN, y: NaN })(event);
+ event.stopPropagation();
+ };
+ }, []);
+
+ const onClick = (event: React.MouseEvent): void => {
+ getOnClick('None', '', { x: NaN, y: NaN })(event);
+ getOnNewVertexClick(defaultNewVertexData)(event);
+ };
+
+ const onWheel = (event: React.WheelEvent): void => {
+ setOffset((prev) => ({ x: prev.x - event.deltaX, y: prev.y - event.deltaY }));
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {[...vertexData].map((el) => {
+ return (
+
+
+
+ );
+ })}
+ {[...todoBlockData].map((el) => {
+ return (
+
+
+
+ );
+ })}
+ {newVertexData.from === '' &&
+ (clickData.type === 'Todo' ? (
+
+ ) : clickData.type === 'Vertex' ? (
+
+ ) : (
+ ''
+ ))}
+ {newVertexData.from !== '' && }
+
+
+
+ }
+ onClick={() => setHasCreateModal(true)}
+ />
+
+ {hasEditModal && }
+ {hasCreateModal && }
+ >
+ );
+};
+
+export default Diagram;
diff --git a/client/src/container/diagram/DiagramFrame.tsx b/client/src/container/diagram/DiagramFrame.tsx
new file mode 100644
index 0000000..66fb794
--- /dev/null
+++ b/client/src/container/diagram/DiagramFrame.tsx
@@ -0,0 +1,31 @@
+import { ReactElement, Suspense, useState } from 'react';
+import styled from 'styled-components';
+import Diagram from '@container/diagram/Diagram';
+import DiagramControlPanel from '@components/diagram/DiagramControlPanel';
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: hidden;
+`;
+
+const DiagramFrame = (): ReactElement => {
+ const [showDone, setShowDone] = useState(false);
+ const onClick = (): void => {
+ setShowDone((prev) => !prev);
+ };
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+export default DiagramFrame;
diff --git a/client/src/container/diagram/DiagramHeader.tsx b/client/src/container/diagram/DiagramHeader.tsx
new file mode 100644
index 0000000..ecd054b
--- /dev/null
+++ b/client/src/container/diagram/DiagramHeader.tsx
@@ -0,0 +1,37 @@
+import { ReactElement } from 'react';
+import styled from 'styled-components';
+import Image from '@components/Image';
+import Text from '@components/Text';
+import Planning from '@images/Planning.svg';
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 100%;
+ height: 65px;
+ z-index: 1;
+`;
+
+const ImageWrapper = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`;
+
+const HeroImage = styled(Image)`
+ position: absolute;
+ transform: translateY(29px);
+`;
+
+const DiagramHeader = (): ReactElement => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default DiagramHeader;
diff --git a/client/src/container/main/TodoContents.tsx b/client/src/container/main/TodoContents.tsx
index 769b525..a6763b3 100644
--- a/client/src/container/main/TodoContents.tsx
+++ b/client/src/container/main/TodoContents.tsx
@@ -1,9 +1,10 @@
-import { ReactElement, useState } from 'react';
+import { ReactElement, useState, memo } from 'react';
import styled from 'styled-components';
import Image from '@components/Image';
import DropDown from '@images/DropDown.svg';
-import { PlainTodo } from '@todo/todo.type';
+import { useAtom } from 'jotai';
+import { asyncActiveTodo } from '@util/GlobalState';
const ContentWrapper = styled.div`
width: 850px;
@@ -37,8 +38,10 @@ const ToggleWrapper = styled.div`
}
`;
-const TodoContents = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement => {
+const TodoContents = (): ReactElement => {
const [isTodoContentToggled, setIsTodoContentToggled] = useState(true);
+ const [activeTodo] = useAtom(asyncActiveTodo);
+
const checkHandler = (): void => {
setIsTodoContentToggled(!isTodoContentToggled);
};
@@ -48,7 +51,7 @@ const TodoContents = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement =
@@ -56,4 +59,4 @@ const TodoContents = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement =
>
);
};
-export default TodoContents;
+export default memo(TodoContents);
diff --git a/client/src/container/main/TodoStatus.tsx b/client/src/container/main/TodoStatus.tsx
index 9ac4a0e..1120d5b 100644
--- a/client/src/container/main/TodoStatus.tsx
+++ b/client/src/container/main/TodoStatus.tsx
@@ -1,13 +1,13 @@
-import { ReactElement } from 'react';
+import { ReactElement, memo } from 'react';
import Text from '@components/Text';
import Image from '@components/Image';
-import { PlainTodo } from '@todo/todo.type';
-import { isOnProgress } from '@util/GlobalState';
+import { isOnProgress, asyncActiveTodo } from '@util/GlobalState';
import { useAtom } from 'jotai';
import Working from '@images/Working.svg';
-import Relaxing from '@images/Relaxing.svg';
+import Relaxing from '@images/Relaxing';
+
import styled from 'styled-components';
import { todoStatusText } from '@util/Common';
@@ -32,18 +32,22 @@ const BlankBox = styled.div`
height: 21px;
`;
-const TodoStatus = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement => {
+const transform = 'translateY(54px)';
+
+const TodoStatus = (): ReactElement => {
const [userState] = useAtom(isOnProgress);
+ const [activeTodo] = useAtom(asyncActiveTodo);
+
return (
<>
-
+ {userState === 'working' ? : }
@@ -51,4 +55,4 @@ const TodoStatus = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement =>
);
};
-export default TodoStatus;
+export default memo(TodoStatus);
diff --git a/client/src/container/main/TodoTimeInteraction.tsx b/client/src/container/main/TodoTimeInteraction.tsx
index 4a0e396..4e4b504 100644
--- a/client/src/container/main/TodoTimeInteraction.tsx
+++ b/client/src/container/main/TodoTimeInteraction.tsx
@@ -6,13 +6,7 @@ import PostponeBox from '@components/main/PostponeBox';
import TodoInteractionButton from '@components/main/TodoInteractionButton';
import TodoTimeText from '@components/main/TodoTimeText';
-import { PlainTodo } from '@todo/todo.type';
-
-import { isOnProgress, postponeClicked } from '@util/GlobalState';
-
-import useTodoList from '../../hooks/useTodoList';
-import useElapsedTime from '../../hooks/useElapsedTime';
-import useButtonConfig from '../../hooks/useButtonConfig';
+import { postponeClicked } from '@util/GlobalState';
const Wrapper = styled.div`
width: 850px;
@@ -22,26 +16,20 @@ const Wrapper = styled.div`
position: relative;
`;
-const TodoTimeInteraction = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement => {
+const ButtonWrapper = styled.div`
+ display: flex;
+`;
+
+const TodoTimeInteraction = (): ReactElement => {
const [isPostpone] = useAtom(postponeClicked);
- const [userState] = useAtom(isOnProgress);
- const [setPostpone, , postponeOptions] = useTodoList();
- const [, , , time, setTime] = useElapsedTime();
- const [buttonConfig, handleOnToggle] = useButtonConfig(userState);
return (
-
- {activeTodo.until !== undefined && }
- {isPostpone && (
-
- )}
+
+
+
+ {isPostpone && }
+
);
};
diff --git a/client/src/container/main/TodoTitle.tsx b/client/src/container/main/TodoTitle.tsx
index 804595f..20945ea 100644
--- a/client/src/container/main/TodoTitle.tsx
+++ b/client/src/container/main/TodoTitle.tsx
@@ -1,20 +1,23 @@
-import { ReactElement } from 'react';
+import { ReactElement, memo } from 'react';
import styled from 'styled-components';
+import { useAtom } from 'jotai';
import Text from '@components/Text';
-import { PlainTodo } from '@todo/todo.type';
+import { asyncActiveTodo } from '@util/GlobalState';
const Wrapper = styled.div`
width: 850px;
text-align: center;
`;
-const TodoTitle = ({ activeTodo }: { activeTodo: PlainTodo }): ReactElement => {
+const TodoTitle = (): ReactElement => {
+ const [activeTodo] = useAtom(asyncActiveTodo);
+
return (
-
+
);
};
-export default TodoTitle;
+export default memo(TodoTitle);
diff --git a/client/src/container/todos/Table.tsx b/client/src/container/todos/Table.tsx
index d3ff034..d5a2cb8 100644
--- a/client/src/container/todos/Table.tsx
+++ b/client/src/container/todos/Table.tsx
@@ -1,81 +1,58 @@
-import { ReactElement, useEffect, useState } from 'react';
+import { ReactElement, useEffect, useState, memo } from 'react';
import styled from 'styled-components';
-import TableHeader from '@components/todos/TableHeader';
-import TableRow from '@components/todos/TableRow';
import { useAtom } from 'jotai';
-import { PlainTodo } from '@todo/todo.type';
-import { todoList, displayDetailAtom } from '@util/GlobalState.js';
import { toast } from 'react-toastify';
-const Wrapper = styled.div`
- width: 85%;
-`;
+import { PlainTodo } from '@todo/todo.type';
+import { todoList } from '@util/GlobalState.js';
+import { Todo } from '@todo/todo';
-const BlankTableWrapper = styled.div`
- text-align: center;
- margin: 10%;
-`;
+import TableHeader from '@components/todos/TableHeader';
+import TableRow from '@components/todos/TableRow';
+import BlankTableInform from '@components/todos/BlankTableInform';
+import { FilterType } from '@util/todos.util';
-const GridWrapper = styled.div`
- display: grid;
- align-items: center;
- grid-template-columns: 1fr 3.5fr 1fr 2fr 1fr 2fr 2fr 1fr;
- border-bottom: 2px solid #e2e2e2;
- text-align: center;
- p {
- margin: 10px 0;
- }
+const Wrapper = styled.div`
+ width: 85%;
+ height: 90%;
+ overflow-y: auto;
+ position: relative;
`;
-const GridRowWrapper = styled(GridWrapper)`
- div:nth-child(10) {
- grid-column: 2/9;
- }
-`;
-const RowWrapper = styled.div`
- ${GridWrapper}:hover {
- background-color: #e2e2e2;
- }
-`;
+const getFilterCallback = (filterSet: Set): ((todo: Todo) => boolean) => {
+ const filterArr = [...filterSet];
+ return (todo: Todo): boolean => filterArr.some((el) => todo.state === el);
+};
const Table = (): ReactElement => {
const [todoListAtom] = useAtom(todoList);
const [todos, setTodos] = useState([]);
- const [displayDetail, setDisplayDetail] = useAtom(displayDetailAtom);
+ const [filter, setFilter] = useState>(new Set(['READY']));
+ const [sort, setSort] = useState }>
+
+ {activeTodo !== undefined ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+
+
+
Todo가 없습니다!
+
+ 여기
+
+
+
+
+ )}
+
+
);
};
diff --git a/client/src/page/Todos.tsx b/client/src/page/Todos.tsx
index b78e99c..2bd6b24 100644
--- a/client/src/page/Todos.tsx
+++ b/client/src/page/Todos.tsx
@@ -1,51 +1,51 @@
-import { ReactElement } from 'react';
+import { ReactElement, Suspense, useState, memo } from 'react';
import styled from 'styled-components';
-import { useAtom } from 'jotai';
-import { modalTypeAtom } from '@util/GlobalState';
-import { TABLE_MODALS } from '@util/Constants';
-
-import TableModal from '@container/todos/TableModal';
import Image from '@components/Image';
import Button from '@components/Button';
import TodosHeader from '@container/todos/TodosHeader';
import Table from '@container/todos/Table';
-import Create from '../images/Create.svg';
+import Create from '@images/Create.svg';
+
+import CreateModal from '@container/CreateModal';
const Wrapper = styled.div`
- height: 90vh;
+ height: 78vh;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
position: relative;
+ overflow: hidden;
`;
const StyledButton = styled.div`
position: fixed;
- bottom: 5%;
+ bottom: 10vh;
right: 5%;
width: 80px;
height: 80px;
`;
const Todos = (): ReactElement => {
- const [modalType, setModalType] = useAtom(modalTypeAtom);
- const hanldeOnClick = (): void => {
- setModalType(TABLE_MODALS.create);
- };
+ const [hasCreateModal, setHasCreateModal] = useState(false);
return (
-
-
-
-
- } onClick={hanldeOnClick} />
-
- {modalType !== TABLE_MODALS.none && }
-
+ loading}>
+
+
+
+
+ }
+ onClick={() => setHasCreateModal(true)}
+ />
+
+ {hasCreateModal && }
+
+
);
};
-export default Todos;
+export default memo(Todos);
diff --git a/client/src/util/Common.ts b/client/src/util/Common.ts
index 2762053..c4d0b85 100644
--- a/client/src/util/Common.ts
+++ b/client/src/util/Common.ts
@@ -1,3 +1,6 @@
+import { PlainTodo } from '@todo/todo.type';
+import { toast } from 'react-toastify';
+
export const todoStatusText = (todoUntil: string): string => {
return isTodoImminence(todoUntil) ? '오늘까지 해야하는 일!' : '오늘 안해도 되는 일';
};
@@ -8,12 +11,15 @@ export const isTodoImminence = (todoUntil: string): boolean => {
return todoDate.getDate() === today.getDate() && todoDate.getMonth() === today.getMonth();
};
-export const getTodoUntilText = (todoUntil: string): string => {
- const untilDate = new Date(todoUntil);
+export const getTodoUntilText = (todoUntil: Date): string => {
+ if (todoUntil === undefined || todoUntil === null) {
+ return '';
+ }
+
return '마감일: '.concat(
- isTodoImminence(todoUntil)
- ? `오늘 ${untilDate.getHours()}시 ${untilDate.getMinutes()}분`
- : getFormattedDate(todoUntil),
+ isTodoImminence(todoUntil.toString())
+ ? `오늘 ${todoUntil.getHours()}시 ${todoUntil.getMinutes()}분`
+ : getFormattedDate(todoUntil.toString()),
);
};
@@ -34,8 +40,9 @@ export const getyyyymmddDateFormat = (date: Date, separator: string): string =>
};
export const gethhmmFormat = (date: Date): string => {
+ const hh = date.getHours();
const mm = date.getMinutes();
- return [date.getHours(), (mm > 9 ? '' : '0') + `${mm}`].join(':');
+ return [(hh > 9 ? '' : '0') + `${hh}`, (mm > 9 ? '' : '0') + `${mm}`].join(':');
};
export const getModalValues = (div: Element): any[] => {
@@ -43,5 +50,33 @@ export const getModalValues = (div: Element): any[] => {
};
export const getTodayDate = (): string => {
- return new Date().toJSON().split('T')[0];
+ const todayDate = new Date();
+ return new Date(-todayDate.getTimezoneOffset() * 60000 + todayDate.getTime()).toISOString().slice(0, -8);
+};
+
+export const getDateTimeInputFormatString = (date: Date): string => {
+ return new Date(-date.getTimezoneOffset() * 60000 + date.getTime()).toISOString().slice(0, -8);
+};
+
+export const copyToClipboard = (text: string): void => {
+ navigator.clipboard.writeText(text).then(
+ () => {
+ toast.success('복사 성공');
+ },
+ () => {
+ toast.error('복사 실패');
+ },
+ );
+};
+
+export const isPlainTodo = (arg: any): arg is PlainTodo => {
+ return arg.content !== undefined;
+};
+
+export const getElapsedTimeText = (time: number): string => {
+ const hour = Math.floor(time / 60 / 60);
+ const minute = Math.floor((time % 3600) / 60);
+ const second = time % 60;
+
+ return `${hour}h ${minute}m ${second}s`;
};
diff --git a/client/src/util/Constants.ts b/client/src/util/Constants.ts
index e2edc8d..fea6d8c 100644
--- a/client/src/util/Constants.ts
+++ b/client/src/util/Constants.ts
@@ -1,9 +1,5 @@
import { TodoList } from '@core/todo/todoList';
-interface ImportanceType {
- [key: string]: string;
-}
-
export const ACTIVE_TODO_STATE = {
working: 'working',
relaxing: 'relaxing',
@@ -37,23 +33,16 @@ export const PRIMARY_COLORS = {
red: '#FE654F',
white: '#FFFFFF',
offWhite: '#FCFCFC',
+ lightestGray: '#F4F4F4',
lightGray: '#E2E2E2',
gray: '#5C5C5C',
darkGray: '#3F3F3F',
+ darkestGray: '#262626',
black: '#1D1D1D',
brown: '#312317',
blue: '#6C9A8B',
-};
-
-export const IMPORTANCE_ALPHABET: ImportanceType = {
- 1: 'C',
- 2: 'B',
- 3: 'A',
-};
-export const TODO_STATE_TEXT: ImportanceType = {
- READY: '작업 가능',
- DONE: '완료',
- WAIT: '대기중',
+ green: '#93C692',
+ yellow: '#FEA34F',
};
export const INITIAL_TODO = { id: undefined, importance: 1, until: new Date() };
@@ -66,12 +55,22 @@ export enum TABLE_MODALS {
}
export const MODAL_INPUT_LIST = [
- { label: '제목', maxLength: 50, type: 'text' },
- { label: '상세 내용', maxLength: Number.MAX_VALUE, type: 'textarea' },
- { label: '마감일', type: 'date', maxLength: -1 },
- { label: '중요도', type: 'select', maxLength: -1 },
- { label: '먼저 할 일', maxLength: Number.MAX_VALUE, type: 'textarea' },
- { label: '이어서 할 일', maxLength: Number.MAX_VALUE, type: 'textarea' },
+ { label: '제목', maxLength: 50, type: 'text', placeHolder: '할 일의 제목을 입력해주세요' },
+ { label: '상세 내용', maxLength: Number.MAX_VALUE, type: 'textarea', placeHolder: '할 일의 상세내용을 입력해주세요' },
+ { label: '마감일', type: 'datetime-local', maxLength: -1, placeHolder: '' },
+ { label: '중요도', type: 'select', maxLength: -1, placeHolder: '' },
+ {
+ label: '먼저 할 일',
+ maxLength: Number.MAX_VALUE,
+ type: 'search-prev',
+ placeHolder: '먼저 해야하는 할 일의 id값을 넣어주세요. 여러개라면 ,(콤마)로 분리해서 넣어주세요',
+ },
+ {
+ label: '이어서 할 일',
+ maxLength: Number.MAX_VALUE,
+ type: 'search-next',
+ placeHolder: '이어서 해야하는 할 일의 id값을 넣어주세요. 여러개라면 ,(콤마)로 분리해서 넣어주세요',
+ },
];
export const MODAL_LABEL_ID = {
@@ -82,3 +81,44 @@ export const MODAL_LABEL_ID = {
'먼저 할 일': 'prev',
'이어서 할 일': 'next',
};
+
+export interface BottomImageStyle {
+ fill?: string;
+ stroke?: string;
+ width?: string;
+ height?: string;
+}
+
+export const TABLE_ROW_DETAIL_TYPE = {
+ nowTodo: '상세 내용',
+ prevTodoList: '먼저 할일 목록',
+ nextTodoList: '이어서 할일 목록',
+};
+
+interface ImportanceType {
+ [key: string]: string;
+}
+
+export const TODO_STATE_TEXT: ImportanceType = {
+ READY: '작업 가능',
+ DONE: '완료',
+ WAIT: '대기중',
+};
+
+export const IMPORTANCE_ALPHABET: ImportanceType = {
+ 1: 'C',
+ 2: 'B',
+ 3: 'A',
+};
+
+export const KEYBOARD_EVENT_KEY = {
+ DOWN: 'ArrowDown',
+ UP: 'ArrowUp',
+ ENTER: 'Enter',
+};
+
+export const INDEX = {
+ FIRST: 0,
+ NOT_FOUND: -1,
+};
+export const MAX_DATE = '2999-12-31T00:00:00.000Z';
diff --git a/client/src/util/GlobalState.ts b/client/src/util/GlobalState.ts
index cdec0cf..b74e0e7 100644
--- a/client/src/util/GlobalState.ts
+++ b/client/src/util/GlobalState.ts
@@ -1,17 +1,8 @@
import { atom } from 'jotai';
import { createTodoList } from '@todo/todoList.js';
-import { TABLE_MODALS } from './Constants.js';
-// import { Todo } from '@core/todo/todoList.js';
-
-// import sortRawTestCase from '../core/todo/test/sort.data.json';
-
-// const sortTestCase = sortRawTestCase.map((el) => ({
-// tag: el.tag,
-// today: new Date(el.today),
-// data: el.data.map((todo) =>
-// new Todo({ ...todo, owner: 'default owner', state: 'READY', importance: todo.importance as number }).toPlain(),
-// ),
-// }));
+import { POSTPONE_TEXTS, POSTPONE_OPTIONS } from '@util/Constants.js';
+import { isEqualDate } from '@todo/todo.util';
+import { getElapsedTimeText } from './Common';
export const loginStateAtom = atom(true);
export const isOnProgress = atom('relaxing');
@@ -20,19 +11,128 @@ export const readWriteAtom = atom(
(get) => get(loginStateAtom),
(_get, set, newValue: boolean) => set(loginStateAtom, newValue),
);
-// const initTodo = new TodoList(sortTestCase[0].data);
-// const initTodo = createTodoList('IndexedDB');
-// export const todoList = atom(initTodo);
-// export const todoList = atom(async () => await createTodoList('MemoryDB'));
-const todoData = await createTodoList('IndexedDB');
+
+let todoData = await createTodoList('IndexedDB');
+let tutorialTodoData = await createTodoList('MemoryDB');
export const todoList = atom(todoData);
-// export const activeTodoAtom = atom(async (get) => await get(todoList).getActiveTodo());
+export const isTutorialAtom = atom(false);
+
+export const changeIndexedDBtoMemoryAtom = atom(null, (get, set) => {
+ if (!get(isTutorialAtom)) {
+ return;
+ }
+
+ todoData = get(todoList).clone();
+ set(todoList, tutorialTodoData);
+});
+
+export const changeMemorytoIndexedDBAtom = atom(null, (get, set) => {
+ if (get(isTutorialAtom)) {
+ return;
+ }
+ tutorialTodoData = get(todoList).clone();
+ set(todoList, todoData);
+});
+
+export const asyncActiveTodo = atom(
+ async (get) => await get(todoList).getActiveTodo(),
+ async (get, set, newValue) => {
+ get(todoList)
+ .getActiveTodo()
+ .then(async (newActiveTodo) => {
+ return await set(asyncActiveTodo, newActiveTodo);
+ })
+ .catch((err) => {
+ throw new Error(err.message);
+ });
+ },
+);
+
+export const elapsedTimeAtom = atom(0); // 초 단위 경과시간
-export const elasedTimeAtom = atom(0); // 초 단위 경과시간
-export const startTimeAtom = atom(new Date());
export const postponeClicked = atom(false);
export const isFinishedAtom = atom(false);
-export const displayDetailAtom = atom('');
-export const modalTypeAtom = atom(TABLE_MODALS.none);
-export const editingTodoIdAtom = atom('');
+export const postpone = atom(['']);
+
+export const postponeOptionsAtom = atom(
+ (get) => get(postpone),
+ (get, set, newValue) => {
+ const activeTodo = get(asyncActiveTodo);
+ if (activeTodo === undefined) {
+ return set(postpone, []);
+ }
+ set(
+ postpone,
+ POSTPONE_TEXTS.filter((x) => {
+ if (activeTodo.importance <= 1 && x === POSTPONE_OPTIONS['우선순위 낮추기']) {
+ return false;
+ }
+ if (isEqualDate(new Date(), activeTodo.until) && x === POSTPONE_OPTIONS['하루 미루기']) {
+ return false;
+ }
+ if (!isEqualDate(new Date(), activeTodo.until) && x === POSTPONE_OPTIONS['데드라인 미루기']) {
+ return false;
+ }
+ return true;
+ }),
+ );
+ },
+);
+
+export const globalTimerAtom = atom(-1);
+
+export const setTimerAtom = atom(
+ (get) => get(globalTimerAtom),
+ (get, set) => {
+ const timer = get(globalTimerAtom);
+ if (timer === -1) {
+ set(
+ globalTimerAtom,
+ window.setInterval(() => {
+ set(elapsedTimeAtom, get(elapsedTimeAtom) + 1);
+ }, 1000),
+ );
+ set(isOnProgress, 'working'); // start
+ return;
+ }
+
+ clearInterval(timer);
+ set(globalTimerAtom, -1);
+ set(isOnProgress, 'relaxing'); // stop
+ },
+);
+
+export const stopTimerAtom = atom(null, (get, set) => {
+ const timer = get(globalTimerAtom);
+ if (timer !== -1) {
+ clearInterval(timer);
+ set(globalTimerAtom, -1);
+ set(isOnProgress, 'relaxing'); // stop
+ }
+});
+
+export const displayTime = atom('');
+export const displayTimeAtom = atom(
+ (get) => get(displayTime),
+ (get, set) => {
+ const time = get(elapsedTimeAtom);
+ set(displayTime, `소요시간: ${getElapsedTimeText(time)}`);
+ },
+);
+
+export const isMainPage = atom(true);
+export const isMainPageAtom = atom(
+ (get) => get(isMainPage),
+ (_get, set) => {
+ set(isMainPage, location.pathname === '/' || location.pathname === '/tutorials/');
+ },
+);
+
+export const needTodoController = atom(true);
+export const needTodoControllerAtom = atom(
+ (get) => get(needTodoController),
+ (get, set) => {
+ set(needTodoController, !get(isMainPage) && get(asyncActiveTodo) !== undefined);
+ },
+);
diff --git a/client/src/util/GlobalStyle.tsx b/client/src/util/GlobalStyle.tsx
index 6b6c2ec..eea0c12 100644
--- a/client/src/util/GlobalStyle.tsx
+++ b/client/src/util/GlobalStyle.tsx
@@ -1,7 +1,6 @@
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
- @import url('https://fonts.googleapis.com/css2?family=Nanum+Myeongjo:wght@400;700;800&family=Noto+Sans+KR:wght@100;300;400;500;700;900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');
*, *::before, *::after {
box-sizing: border-box;
@@ -12,6 +11,7 @@ const GlobalStyle = createGlobalStyle`
font-family: 'Nanum Myeongjo','Noto Sans KR','Roboto', sans-serif;
}
#root {
+ position: relative;
height: 100%;
margin: 0;
}
@@ -44,6 +44,9 @@ const GlobalStyle = createGlobalStyle`
button:hover {
cursor: pointer;
}
+ .Toastify__toast-container {
+ z-index: 300000000;
+ }
`;
export default GlobalStyle;
diff --git a/client/src/util/diagram.util.ts b/client/src/util/diagram.util.ts
new file mode 100644
index 0000000..a9cc918
--- /dev/null
+++ b/client/src/util/diagram.util.ts
@@ -0,0 +1,272 @@
+import { TodoList } from '@todo/todoList';
+import { Todo } from '@todo/todo';
+import { PlainTodo } from '@todo/todo.type';
+import Queue from '@util/queue';
+
+export interface DiagramTodo {
+ order?: number;
+ depth?: number;
+ todo: Todo;
+}
+
+const topologySort = async (todoList: TodoList, showDone: boolean): Promise> => {
+ const filter = showDone ? () => true : (el: Todo | PlainTodo) => el.state !== 'DONE';
+ const sortedTodoList = await todoList.getSortedListWithFilter(filter, []);
+ const cloneTodoList = new Map(sortedTodoList.map((el) => [el.id, new Todo(el)]));
+ // Diagram에서 사용하지 않는 선후관계 의존성 제거
+ cloneTodoList.forEach((el) => {
+ el.prev.forEach((prevId) => {
+ if (!cloneTodoList.has(prevId)) el.prev.delete(prevId);
+ });
+ el.next.forEach((nextId) => {
+ if (!cloneTodoList.has(nextId)) el.next.delete(nextId);
+ });
+ });
+ // 결과값 템플릿 오브젝트 생성
+ const resultTodoList = new Map(
+ sortedTodoList.map((el) => [el.id, { depth: NaN, todo: new Todo(el) }]),
+ );
+ // 결과값들에서도 Diagram에서 사용하지 않는 선후관계 의존성 제거
+ resultTodoList.forEach((el) => {
+ el.todo.prev.forEach((prevId) => {
+ if (!cloneTodoList.has(prevId)) el.todo.prev.delete(prevId);
+ });
+ el.todo.next.forEach((nextId) => {
+ if (!cloneTodoList.has(nextId)) el.todo.next.delete(nextId);
+ });
+ });
+
+ const updateDepth = (id: string, depth: number): void => {
+ const target = resultTodoList.get(id);
+ if (target !== undefined) {
+ resultTodoList.set(id, { ...target, depth });
+ }
+ };
+
+ const checkPrev = (id: string): boolean => {
+ const target = cloneTodoList.get(id);
+ if (target == null) throw new Error('ERROR: 찾으려는 id의 Todo가 없습니다.');
+ return target.prev.size === 0;
+ };
+
+ const zeroDepthTodoList = sortedTodoList
+ .filter((el) => filter(el) && checkPrev(el.id))
+ .map((el) => ({ depth: 0, id: el.id }));
+
+ const forwardQueue = new Queue(zeroDepthTodoList);
+ while (!forwardQueue.isEmpty()) {
+ const target = forwardQueue.pop();
+ updateDepth(target.id, target.depth);
+ const todo = cloneTodoList.get(target.id);
+ if (todo === undefined) continue;
+ [...todo.next].forEach((nextId) => {
+ const nextTodo = cloneTodoList.get(nextId);
+ if (nextTodo === undefined) return;
+ nextTodo.prev.delete(target.id);
+ if (nextTodo.prev.size === 0) {
+ forwardQueue.push({ depth: target.depth + 1, id: nextId });
+ }
+ });
+ }
+
+ const baseDepth = resultTodoList.get(sortedTodoList.find((el) => el.state === 'READY')?.id as string)?.depth ?? 0;
+ resultTodoList.forEach((el) => ((el.depth as number) -= baseDepth));
+
+ return resultTodoList;
+};
+
+const calcOrder = (todoList: Map): Map => {
+ const todoListArr = [...todoList];
+ let j = 0;
+ todoListArr.forEach((el, i, arr) => {
+ const todo = el[1];
+ if (i !== 0 && todo.depth === arr[i - 1][1].depth) j++;
+ todo.order = i + j;
+ });
+ const offset = todoListArr.find((el) => el[1].todo.state === 'READY')?.[1].order ?? 0;
+ return new Map(todoListArr.map((el) => [el[0], { ...el[1], order: (el[1].order as number) - offset }]));
+};
+
+export const getDiagramData = async (todoList: TodoList, showDone: boolean): Promise> => {
+ return calcOrder(await topologySort(todoList, showDone));
+};
+
+const MARGIN = {
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+};
+
+const GAP = {
+ x: 55,
+ y: 85,
+};
+
+export const BLOCK = {
+ x: 225,
+ y: 75,
+};
+
+export const calculatePosition = (order: number, depth: number): { x: number; y: number } => {
+ return { x: MARGIN.left + ((GAP.x + BLOCK.x) * order) / 2, y: MARGIN.top + (GAP.y + BLOCK.y) * depth };
+};
+
+export interface Vertex {
+ from: string;
+ to: string;
+}
+
+export const getVertice = (todoList: Map): Vertex[] => {
+ const todoListArr = [...todoList];
+ const verticeArr: Vertex[] = [];
+ todoListArr.forEach((el) => {
+ el[1].todo.next.forEach((nextId) => {
+ verticeArr.push({ from: el[1].todo.id, to: nextId });
+ });
+ });
+ return verticeArr;
+};
+
+export const getVertexDimension = (
+ todoList: Map,
+ vertex: Vertex,
+): {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+} => {
+ const from = todoList.get(vertex.from);
+ const to = todoList.get(vertex.to);
+ if (from == null || to == null) throw new Error('ERROR: 선후관계가 잘못된 레퍼런스를 참조하고 있습니다.');
+ const fromPos = calculatePosition(from.order as number, from.depth as number);
+ const toPos = calculatePosition(to.order as number, to.depth as number);
+ return {
+ x1: fromPos.x + BLOCK.x / 2,
+ y1: fromPos.y + BLOCK.y,
+ x2: toPos.x + BLOCK.x / 2,
+ y2: toPos.y,
+ };
+};
+
+export const getVertexFromPosition = (todoList: Map, id: string): { x1: number; y1: number } => {
+ const from = todoList.get(id);
+ if (from === undefined) throw new Error('ERROR: 선후관계가 잘못된 레퍼런스를 참조하고 있습니다.');
+ const fromPos = calculatePosition(from.order as number, from.depth as number);
+ return {
+ x1: fromPos.x + BLOCK.x / 2,
+ y1: fromPos.y + BLOCK.y,
+ };
+};
+
+export const getVertexToPosition = (todoList: Map, id: string): { x2: number; y2: number } => {
+ const to = todoList.get(id);
+ if (to === undefined) throw new Error('ERROR: 선후관계가 잘못된 레퍼런스를 참조하고 있습니다.');
+ const toPos = calculatePosition(to.order as number, to.depth as number);
+ return {
+ x2: toPos.x + BLOCK.x / 2,
+ y2: toPos.y,
+ };
+};
+
+export const validateVertex = (todoList: Map, vertex: Vertex): 'NORMAL' | 'WARNING' | 'ERROR' => {
+ const from = todoList.get(vertex.from);
+ const to = todoList.get(vertex.to);
+ if (from == null || to == null) throw new Error('ERROR: 선후관계가 잘못된 레퍼런스를 참조하고 있습니다.');
+ if (from.todo.until.getTime() > to.todo.until.getTime()) return 'ERROR';
+ if ((from.order as number) > (to.order as number)) return 'WARNING';
+ return 'NORMAL';
+};
+
+export interface VertexProps {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ type: 'NORMAL' | 'WARNING' | 'ERROR';
+ id: string;
+}
+
+export const getVerticeProps = (todoList: Map): Map => {
+ const verticeArr = getVertice(todoList);
+ return new Map(
+ verticeArr.map((el) => {
+ const pos = getVertexDimension(todoList, el);
+ const type = validateVertex(todoList, el);
+ const id = `${el.from}+${el.to}`;
+ return [id, { id, ...pos, type }];
+ }),
+ );
+};
+
+export interface TodoBlockProps {
+ todo: Todo;
+ x: number;
+ y: number;
+ id: string;
+}
+
+export const getTodoBlockProps = (todoList: Map): Map => {
+ return new Map(
+ [...todoList].map((el) => {
+ const pos = calculatePosition(el[1].order as number, el[1].depth as number);
+ return [el[0], { id: el[0], todo: el[1].todo, ...pos }];
+ }),
+ );
+};
+
+const BOX_OFFSET = 50;
+
+export const getPathValue = (
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+): { path: string; width: number; height: number; viewBox: string; translateX: number; translateY: number } => {
+ const p1 = { x: 0, y: 0 };
+ const c1 = { x: 0, y: 0.4 * Math.abs(y2 - y1) };
+ const c2 = { x: x2 - x1, y: y2 - y1 - 0.4 * Math.abs(y2 - y1) };
+ const p2 = { x: x2 - x1, y: y2 - y1 };
+ const start = { x: Math.min(p1.x, c1.x, c2.x, p2.x), y: Math.min(p1.y, c1.y, c2.y, p2.y) };
+ const end = { x: Math.max(p1.x, c1.x, c2.x, p2.x), y: Math.max(p1.y, c1.y, c2.y, p2.y) };
+ const path = `M${BOX_OFFSET + p1.x - start.x} ${BOX_OFFSET + p1.y - start.y}C${BOX_OFFSET + c1.x - start.x} ${
+ BOX_OFFSET + c1.y - start.y
+ } ${BOX_OFFSET + c2.x - start.x} ${BOX_OFFSET + c2.y - start.y} ${BOX_OFFSET + p2.x - start.x} ${
+ BOX_OFFSET + p2.y - start.y
+ }`;
+ const width = end.x - start.x + 2 * BOX_OFFSET;
+ const height = end.y - start.y + 2 * BOX_OFFSET;
+ const viewBox = `${start.x} ${start.y} ${width} ${height}`;
+ const translateX = -p1.x + start.x - BOX_OFFSET;
+ const translateY = -Math.abs(p1.y - start.y) - BOX_OFFSET;
+ return { path, width, height, viewBox, translateX, translateY };
+};
+
+// export const getPathValue = (
+// x1: number,
+// y1: number,
+// x2: number,
+// y2: number,
+// ): { path: string; width: number; height: number; viewBox: string; translateX: number; translateY: number } => {
+// const p1 = { x: 0, y: 0 };
+// const c1 = { x: 0, y: 0.4 };
+// const c2 = { x: Math.sign(x2 - x1), y: Math.sign(y2 - y1) - 0.4 };
+// const p2 = { x: Math.sign(x2 - x1), y: Math.sign(y2 - y1) };
+// const start = { x: Math.min(p1.x, c1.x, c2.x, p2.x), y: Math.min(p1.y, c1.y, c2.y, p2.y) };
+// const end = { x: Math.max(p1.x, c1.x, c2.x, p2.x), y: Math.max(p1.y, c1.y, c2.y, p2.y) };
+// const path = `M${p1.x} ${p1.y}C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${p2.x} ${p2.y}`;
+// const width = end.x - start.x;
+// const height = end.y - start.y;
+// const viewBox = `${start.x} ${start.y} ${width} ${height}`;
+// const translateX = (-p1.x + start.x) * Math.abs(x2 - x1);
+// const translateY = -Math.abs(p1.y - start.y) * Math.abs(y2 - y1);
+// return {
+// path,
+// width: width * Math.abs(x2 - x1),
+// height: height * Math.abs(y2 - y1),
+// viewBox,
+// translateX,
+// translateY,
+// };
+// };
diff --git a/client/src/util/modal.util.ts b/client/src/util/modal.util.ts
new file mode 100644
index 0000000..9aaf336
--- /dev/null
+++ b/client/src/util/modal.util.ts
@@ -0,0 +1,82 @@
+import { getTodayDate } from './Common';
+import { MAX_DATE } from './Constants';
+
+export const validatePrevAndNextId = (uuidArray: string[]): boolean => {
+ const regex = /(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)/;
+ if (uuidArray.length > 0) {
+ uuidArray.forEach((uuid: string) => {
+ if (!regex.test(uuid)) throw new Error('id가 올바르지 않게 입력되었습니다');
+ });
+ } else {
+ return true;
+ }
+ return true;
+};
+
+export const uuidSeriesTextToUuidArray = (uuidSeriesText: string): string[] => {
+ return uuidSeriesText
+ .replaceAll('\n', '')
+ .replaceAll(' ', '')
+ .split(',')
+ .filter((id: string) => id !== '');
+};
+
+export const validateCircularReference = (idList: string[], checkId: string): boolean => {
+ return idList.some((id) => id === checkId);
+};
+
+export const validateUuid = (uuid: string): boolean => {
+ const regex = /(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)/;
+ if (!regex.test(uuid)) throw new Error('먼저 할 일 또는 나중에 할 일의 id값이 올바르지 않습니다');
+ return true;
+};
+
+interface ModalValues {
+ id: string;
+ value: string;
+ dataset: { label: string; id: string };
+}
+
+const MODAL_CREATE = 'create';
+
+interface CheckedInputData {
+ newData: any;
+ prev: string[];
+ next: string[];
+}
+export const getCheckedInputData = (type: string, inputData: ModalValues[]): CheckedInputData => {
+ let newData = {};
+ const prevTodoIdList: string[] = [];
+ const nextTodoIdList: string[] = [];
+
+ inputData.forEach((item) => {
+ const { id, value, dataset }: ModalValues = item;
+
+ if (id === 'title' && value === '') {
+ throw new Error('제목은 필수 값입니다!');
+ }
+ if (id === 'title' && value.match(/[.*+?^${}()|[\]\\]/) !== null) {
+ throw new Error('제목은 ".*+?^${}()"의 특수문자가 허용되지 않습니다!');
+ }
+ if (id === 'until') {
+ const newDate = new Date(value);
+ if (isNaN(Number(newDate))) {
+ throw new Error('유효하지 않은 날짜입니다!');
+ }
+ if (newDate > new Date(MAX_DATE)) {
+ throw new Error('날짜는 2999-12-30 이후로 설정할 수 없습니다.');
+ }
+ if (type === MODAL_CREATE && newDate < new Date(getTodayDate())) {
+ throw new Error('새로 생성하는 할 일은 과거로 설정 불가능합니다.');
+ }
+ return (newData = { ...newData, [id]: newDate });
+ }
+
+ if (dataset.label === 'prev' || dataset.label === 'next') {
+ return dataset.label === 'prev' ? prevTodoIdList.push(dataset.id) : nextTodoIdList.push(dataset.id);
+ }
+ newData = { ...newData, [id]: value };
+ });
+
+ return { newData, prev: prevTodoIdList, next: nextTodoIdList };
+};
diff --git a/client/src/util/queue.ts b/client/src/util/queue.ts
new file mode 100644
index 0000000..e76e870
--- /dev/null
+++ b/client/src/util/queue.ts
@@ -0,0 +1,49 @@
+interface QueueObj {
+ prev: QueueObj | undefined;
+ next: QueueObj | undefined;
+ value: T | undefined;
+}
+
+class Queue {
+ private head: QueueObj;
+ private tail: QueueObj;
+ #length: number;
+ constructor(defaultArr: T[]) {
+ this.head = { prev: undefined, next: undefined, value: undefined };
+ this.tail = { prev: this.head, next: undefined, value: undefined };
+ this.head.next = this.tail;
+ this.#length = 0;
+ defaultArr.forEach((el) => this.push(el));
+ }
+
+ length(): number {
+ return this.#length;
+ }
+
+ isEmpty(): boolean {
+ if (this.length() === 0) return true;
+ return false;
+ }
+
+ private pushOne(input: T): void {
+ const newQueueObj = { value: input, prev: this.tail.prev, next: this.tail };
+ if (this.tail.prev != null) this.tail.prev.next = newQueueObj;
+ this.tail.prev = newQueueObj;
+ this.#length++;
+ }
+
+ push(...input: T[]): void {
+ input.forEach((el) => this.pushOne(el));
+ }
+
+ pop(): T {
+ const target = this.head.next;
+ if (this.isEmpty() || target === undefined) throw new Error('Error: You cant pop from empty queue');
+ this.head.next = target.next;
+ if (target.next != null) target.next.prev = this.head;
+ this.#length--;
+ return target.value as T;
+ }
+}
+
+export default Queue;
diff --git a/client/src/util/todos.util.ts b/client/src/util/todos.util.ts
new file mode 100644
index 0000000..6590b2e
--- /dev/null
+++ b/client/src/util/todos.util.ts
@@ -0,0 +1,57 @@
+import { PlainTodo } from '@todo/todo.type';
+import ColoredDone from '@images/ColoredDone.svg';
+import ColoredReady from '@images/ColoredReady.svg';
+import ColoredWait from '@images/ColoredWait.svg';
+import ColoredPostponed from '@images/ColoredPostponed.svg';
+import { TodoList } from '@todo/todoList';
+import { toast } from 'react-toastify';
+import { SetStateAction } from 'jotai';
+
+export const getListInfoText = (todoList: PlainTodo[]): string => {
+ const numberOfTitleLength = 6;
+ const listLength = todoList?.length;
+ if (listLength === 0) return '-';
+
+ const firstTodoTitle = todoList[0].title;
+ const shortenTodoTitle =
+ firstTodoTitle.length > numberOfTitleLength
+ ? [firstTodoTitle.slice(0, numberOfTitleLength), '...'].join('')
+ : firstTodoTitle;
+ return listLength === 1 ? shortenTodoTitle : [shortenTodoTitle, '외', listLength - 1].join(' ');
+};
+
+export type FilterType = 'READY' | 'WAIT' | 'DONE';
+
+export const getTodoStateIcon = (todo: PlainTodo): string => {
+ if (todo.state === 'DONE') return ColoredDone;
+ if (todo.state === 'READY') return ColoredReady;
+ if (todo.from.getTime() > new Date().getTime()) return ColoredPostponed;
+ return ColoredWait;
+};
+
+export const getCheckTodoStateHandler = (
+ todo: PlainTodo,
+ todoListAtom: TodoList,
+ setTodoListAtom: (update: SetStateAction) => void,
+): ((event: React.MouseEvent) => void) => {
+ return (event) => {
+ let newTodo = {};
+ event.stopPropagation();
+ if (todo.state === 'DONE') newTodo = { id: todo.id, state: 'READY' };
+ else if (todo.state === 'READY') newTodo = { id: todo.id, state: 'DONE' };
+ else if (todo.from.getTime() > new Date().getTime()) {
+ toast.error('오늘 하루 동안 보지 않기로 설정한 할일입니다.');
+ return;
+ } else {
+ toast.error('아직 먼저 할 일들이 끝나지 않은 할일입니다.');
+ return;
+ }
+ todoListAtom
+ .edit(todo.id, newTodo)
+ .then((newTodoList) => {
+ setTodoListAtom(newTodoList);
+ toast.success('완료되었습니다.');
+ })
+ .catch((err) => toast.error(err));
+ };
+};
diff --git a/client/src/util/wrapPromise.ts b/client/src/util/wrapPromise.ts
new file mode 100644
index 0000000..778c1f2
--- /dev/null
+++ b/client/src/util/wrapPromise.ts
@@ -0,0 +1,34 @@
+interface wrappedPromise {
+ read: () => T | undefined;
+}
+
+const wrapPromise = (promise: Promise): wrappedPromise => {
+ let status = 'pending'; // 최초의 상태
+ let result: T;
+ // 프로미스 객체 자체
+ const suspender = promise.then(
+ (r) => {
+ status = 'success'; // 성공으로 완결시 success로
+ result = r;
+ },
+ (e) => {
+ status = 'error'; // 실패로 완결시 error로
+ result = e;
+ },
+ );
+ // 위의 Suspense For Data Fetching 예제에서의 read() 메소드입니다.
+ // 위 함수의 로직을 클로저삼아, 함수 밖에서 프로미스의 진행 상황을 읽는 인터페이스가 된다
+ return {
+ read() {
+ if (status === 'pending') {
+ throw suspender; // 펜딩 프로미스를 throw 하면 Suspense의 Fallback UI를 보여준다
+ } else if (status === 'error') {
+ throw result; // Error을 throw하는 경우 ErrorBoundary의 Fallback UI를 보여준다
+ } else if (status === 'success') {
+ return result; // 결과값을 리턴하는 경우 성공 UI를 보여준다
+ }
+ },
+ };
+};
+
+export default wrapPromise;
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 39b704b..55734c5 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -17,15 +17,16 @@
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
- "@": ["src/*"],
+ "@repository/*": ["src/core/repository/*"],
+ "@todo/*": ["src/core/todo/*"],
"@components/*": ["src/components/*"],
"@container/*": ["src/container/*"],
"@core/*": ["src/core/*"],
- "@todo/*": ["src/core/todo/*"],
- "@repository/*": ["src/core/repository/*"],
"@images/*": ["src/images/*"],
"@page/*": ["src/page/*"],
- "@util/*": ["src/util/*"]
+ "@util/*": ["src/util/*"],
+ "@hooks/*": ["src/hooks/*"],
+ "@": ["src/*"],
}
},
"include": [".eslintrc.cjs", "src", "jest.config.cjs"],
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 51e70da..7277f44 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -18,6 +18,7 @@ export default defineConfig({
'@images': resolve(__dirname, './src/images'),
'@page': resolve(__dirname, './src/page'),
'@util': resolve(__dirname, './src/util'),
+ '@hooks': resolve(__dirname, './src/hooks'),
},
},
});
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
new file mode 100644
index 0000000..da1d401
--- /dev/null
+++ b/docker-compose.production.yml
@@ -0,0 +1,21 @@
+version: "3.9"
+services:
+ proxy:
+ image: "ghcr.io/kumsil1006/oao-proxy:latest"
+ ports:
+ - "80:80"
+ - "443:443"
+ restart: always
+ volumes:
+ - ./ssl/certificate.crt:/etc/ssl/certificate.crt
+ - ./ssl/private.key:/etc/ssl/private.key
+ frontend:
+ image: "ghcr.io/kumsil1006/oao-client:latest"
+ restart: always
+ expose:
+ - "3000"
+ backend:
+ image: "ghcr.io/kumsil1006/oao-server:latest"
+ restart: always
+ expose:
+ - "8080"
diff --git a/nginx/Dockerfile.production b/nginx/Dockerfile.production
new file mode 100644
index 0000000..9de72f6
--- /dev/null
+++ b/nginx/Dockerfile.production
@@ -0,0 +1,4 @@
+FROM nginx
+COPY ./default.production.conf /etc/nginx/conf.d/default.conf
+
+RUN apt-get update && apt-get install vim -y
\ No newline at end of file
diff --git a/nginx/default.production.conf b/nginx/default.production.conf
new file mode 100644
index 0000000..8669ff4
--- /dev/null
+++ b/nginx/default.production.conf
@@ -0,0 +1,36 @@
+server {
+ listen 80;
+ server_name oneatonce.com;
+ server_tokens off;
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+server {
+ listen 443 ssl;
+ server_name oneatonce.com;
+ server_tokens off;
+ ssl_certificate /etc/ssl/certificate.crt;
+ ssl_certificate_key /etc/ssl/private.key;
+
+ location / {
+ proxy_pass http://frontend:3000;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+ location /api {
+ rewrite ^/api(.*)$ $1 break;
+ proxy_pass http://backend:8080;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+}
\ No newline at end of file