diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..fce2a2a --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,34 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + ], + overrides: [ + { + env: { + node: true, + }, + files: ['.eslintrc.{js,cjs}'], + parserOptions: { + sourceType: 'script', + }, + }, + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'react'], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/jsx-curly-brace-presence': 'error', + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': 'off', // 이 줄을 추가하여 규칙 해제 + }, +}; diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..bb42fb2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,23 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + +## 목적 🪄 +> 어떤 작업을 위한 이슈인지 설명해주세요. + +
+
+ +## 작업 내용 💻 +> 이번 PR에서 작업할 내용을 간략히 설명해주세요. (이미지 첨부 환영) + + +
+
+ +## 스크린샷 📸 (선택) diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\354\203\235\354\204\261-\355\205\234\355\224\214\353\246\277.md" "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\354\203\235\354\204\261-\355\205\234\355\224\214\353\246\277.md" new file mode 100644 index 0000000..b719c23 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\354\203\235\354\204\261-\355\205\234\355\224\214\353\246\277.md" @@ -0,0 +1,13 @@ +--- +name: 이슈 생성 템플릿 +about: 해당 이슈 생성 템플릿을 이용하여 이슈를 생성해주세요 +title: '' +labels: '' +assignees: '' + +--- + +## (제목) +### (페이지) +- [ ] 구현내용1 +- [ ] 구현내용2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6efed93 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## 🪄 목적 + +> 관련 이슈 : # + +
+
+ +## 💻 상세 작업 내용 + +> 이번 PR에서 작업한 내용을 간략히 설명해주세요. + +- + +
+
+ +## 📸 스크린샷 + +
+
+ +## 👼🏻 리뷰 요구사항 (선택) + +> 리뷰어가 특별히 봐줬으면 하는 부분이 있다면 작성해주세요. diff --git a/.gitignore b/.gitignore index 4d29575..8692cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..2785bc1 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +yarn commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..0cd82b6 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2efc7e1 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.11.1 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4159c4b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "jsxSingleQuote": false, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "endOfLine": "auto", + "arrowParens": "avoid", + "bracketSpacing": true +} diff --git a/README.md b/README.md index b58e0af..559df6a 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,16 @@ -# Getting Started with Create React App +## 끄적끄적 FE -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +### FE용 세팅 -## Available Scripts +``` +1. 노드버전을 .nvmrc 파일에 명시된 버전으로 세팅 +nvm use (만약에 본인의 개발환경에 .nvmrc에 명시된 node 버전이 없다면 nvm install을 진행하고 나서 nvm use를 해야함) -In the project directory, you can run: +2. 설치 +yarn install -### `yarn start` +3. pretiter & eslint 에디터 설정 +각자 에디터에 맞게 설정 -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.\ -You will also see any lint errors in the console. - -### `yarn test` - -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - -### `yarn build` - -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! - -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `yarn eject` - -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** - -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). +4. 앱을 실행하려면 yarn start 명령어 입력 +``` diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..e5f670c --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,29 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'header-pattern': [2, 'always', /^\[#(\d+)\]\s(\w+):\s(.+)$/], + 'header-max-length': [2, 'always', 72], + 'type-enum': [ + 2, + 'always', + [ + 'feat', // 기능 (새로운 기능) + 'fix', // 버그 수정 + 'docs', // 문서 (문서 추가, 수정, 삭제) + 'style', // 스타일 (코드 포맷팅, 세미콜론 추가: 비즈니스 로직에 변경 없음) + 'refactor', // 리팩토링 (프로덕션 코드 수정이 없는 경우) + 'perf', // 성능 개선 + 'test', // 테스트 추가, 수정 + 'chore', // 기타 변경사항 (빌드 스크립트 수정 등) + 'revert', // 커밋 되돌리기 + 'build', // 빌드 관련 변경사항 (npm, yarn 등) + ], + ], + }, + parserPreset: { + parserOpts: { + headerPattern: /^\[#(\d+)\]\s(\w+):\s(.+)$/, + headerCorrespondence: ['ticket', 'type', 'subject'], + }, + }, +}; diff --git a/package.json b/package.json index 89ad845..b1d9cc8 100644 --- a/package.json +++ b/package.json @@ -3,24 +3,54 @@ "version": "0.1.0", "private": true, "dependencies": { - "@testing-library/jest-dom": "^5.14.1", + "@blocknote/core": "^0.15.5", + "@blocknote/mantine": "^0.15.5", + "@blocknote/react": "^0.15.5", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/material": "^6.0.2", + "@tanstack/react-query": "^5.52.1", + "@tanstack/react-query-devtools": "^5.52.1", + "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", - "@types/jest": "^27.0.1", + "@types/jest": "^29.5.12", "@types/node": "^16.7.13", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@types/react-modal": "^3.16.3", + "@types/styled-components": "^5.1.34", + "aos": "^2.3.4", + "axios": "^1.7.5", + "date-fns": "^3.6.0", + "framer-motion": "^11.3.8", + "gsap": "^3.12.5", + "jotai": "^2.9.2", "react": "^18.3.1", + "react-autosize-textarea": "^7.1.0", + "react-beautiful-dnd": "^13.1.1", + "react-datepicker": "^7.3.0", "react-dom": "^18.3.1", - "react-scripts": "5.0.1", - "typescript": "^4.4.2", + "react-icons": "^5.2.1", + "react-intersection-observer": "^9.13.0", + "react-modal": "^3.16.1", + "react-paginate": "^8.2.0", + "react-router-dom": "^6.25.1", + "react-scripts": "^5.0.1", + "react-textarea-autosize": "^8.5.3", + "simplex-noise": "^4.0.2", + "styled-components": "^6.1.12", + "styled-reset": "^4.5.2", + "three": "^0.167.0", + "typescript": "^5.5.3", "web-vitals": "^2.1.0" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "prepare": "husky" }, "eslintConfig": { "extends": [ @@ -39,5 +69,28 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "devDependencies": { + "@commitlint/cli": "^19.3.0", + "@commitlint/config-conventional": "^19.2.2", + "@types/aos": "^3.0.7", + "@types/axios": "^0.14.0", + "@types/gsap": "^3.0.0", + "@types/mocha": "^10.0.7", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-datepicker": "^7.0.0", + "@types/testing-library__jest-dom": "^6.0.0", + "@types/three": "^0.166.0", + "@types/unist": "^3.0.3", + "@types/webxr": "^0.5.19", + "@typescript-eslint/eslint-plugin": "^7.16.1", + "@typescript-eslint/parser": "^7.16.1", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.34.4", + "husky": "^9.1.1", + "prettier": "3.3.3" + }, + "proxy": "https://dev.kkeujeok.store" } diff --git a/src/App.tsx b/src/App.tsx index a53698a..06a5075 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,97 @@ -import React from 'react'; -import logo from './logo.svg'; -import './App.css'; +import React, { useEffect, useState } from 'react'; +import { + createBrowserRouter, + RouterProvider, + createRoutesFromElements, + Route, + Navigate, +} from 'react-router-dom'; +import MainPage from './pages/MainPage'; +import LoginPage from './pages/LoginPage'; +import MyPage from './pages/MyPage'; +import CreateBoard from './pages/CreateBoardPage'; +import CreatePersonalBoard from './pages/CreatePersonalBoardPage'; +import CreateTeamBoard from './pages/CreateTeamBoardPage'; +import OAuthRedirectHandler from './contexts/OAuthRedirectHandler'; +import { AuthProvider } from './contexts/AuthContext'; +import TeamDocument from './pages/TeamDocument'; +import TeamDocumentBoard from './pages/TeamDocumentBoard'; +import SidePage from './pages/SidePage'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import AOS from 'aos'; +import 'aos/dist/aos.css'; + +const queryClient = new QueryClient(); + +// 로그인 상태를 체크하는 함수 +const useAuth = () => { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [loading, setLoading] = useState(true); // 로딩 상태 추가 + + useEffect(() => { + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + setIsLoggedIn(true); + } + setLoading(false); // 로딩 상태를 false로 변경 + + AOS.init(); // AOS 초기화 + }, []); + + return { isLoggedIn, loading }; +}; + +// Data Router 사용하여 라우터 설정 +const router = (isLoggedIn: boolean) => + createBrowserRouter( + createRoutesFromElements( + + + ) : ( + + ) + } + /> + }> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + + } /> + + ) + ); + +const App: React.FC = () => { + const { isLoggedIn, loading } = useAuth(); // 로그인 여부와 로딩 상태 체크 + + if (loading) { + // 로딩 중일 때는 아무것도 렌더링하지 않음 + return
Loading...
; + } -function App() { return ( -
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
-
+ + + + + + ); -} +}; export default App; diff --git a/src/api/BoardApi.tsx b/src/api/BoardApi.tsx new file mode 100644 index 0000000..c7dc9b9 --- /dev/null +++ b/src/api/BoardApi.tsx @@ -0,0 +1,116 @@ +import { axiosInstance } from '../utils/apiConfig'; +import { + DashboardItem, + // PersonalDashBoard, + PersonalSearchDashBoard, +} from '../types/PersonalDashBoard'; +import { TeamDashboardResponse } from '../types/TeamDashBoard'; +import { StatusPersonalBlock } from '../types/PersonalBlock'; + +// * 개인 대시보드 블록 get (세로 무한 스크롤) +export const getPersonalBlock = async ( + id: number | string, + page: number = 0, // default 페이지 0으로 설정 + size: number = 10, + progress: string +): Promise => { + try { + const response = await axiosInstance.get( + `/blocks?dashboardId=${id}&progress=${progress}&page=${page}&size=${size}` + ); + return response.data.data as StatusPersonalBlock; + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +// * 개인 대시보드 create +export const createDashBoard = async (data: DashboardItem): Promise => { + try { + const response = await axiosInstance.post('/dashboards/personal/', data); + console.log(response.data); + return response.data.data.dashboardId; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 개인 대시보드 patch +export const patchDashBoard = async ( + dashboardId: string, + data: DashboardItem +): Promise => { + try { + const response = await axiosInstance.patch(`/dashboards/personal/${dashboardId}`, data); + console.log(response); + return response.data.data.dashboardId; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +export const searchPersonalDashBoard = async (): Promise => { + try { + const response = await axiosInstance.get('/dashboards/personal/'); + return response.data as PersonalSearchDashBoard; + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +export const searchTeamDashBoard = async (): Promise => { + try { + const response = await axiosInstance.get('/dashboards/team/'); + return response.data as TeamDashboardResponse; + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +// * 개인 대시보드 생성시 사용자 카테고리 get +export const getCategories = async (): Promise => { + try { + const response = await axiosInstance.get(`/dashboards/personal/categories`); + return response.data.data.categories; + } catch (error) { + console.log('error'); + return null; + } +}; + +// * 개인 대시보드 상세 정보 get +export const getPersonalDashboard = async (id: string): Promise => { + try { + const response = await axiosInstance.get(`/dashboards/personal/${id}`); + return response.data.data; + } catch (error) { + console.log('error'); + return null; + } +}; + +// * 개인 대시보드 삭제 +export const deletePersonalDashboard = async (id: string): Promise => { + try { + const response = await axiosInstance.delete(`/dashboards/personal/${id}`); + // return response.data.data; + console.log(response); + } catch (error) { + console.log('error'); + // return null; + } +}; + +// * 팀 대시보드 탈퇴 +export const quitTeamDashboard = async (id: string): Promise => { + try { + const response = await axiosInstance.delete(`/dashboards/team/${id}/leave}`); + // return response.data.data; + console.log(response); + } catch (error) { + console.log('error'); + // return null; + } +}; diff --git a/src/api/MyPageApi.ts b/src/api/MyPageApi.ts new file mode 100644 index 0000000..896ad07 --- /dev/null +++ b/src/api/MyPageApi.ts @@ -0,0 +1,21 @@ +import { ProfileInfo, ReturnData } from '../types/MyPage'; +import { axiosInstance } from '../utils/apiConfig'; + +export const fetchData = async (): Promise => { + try { + const res = await axiosInstance.get('/members/mypage'); + return res.data as ProfileInfo; + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +export const fetchBlockData = async (): Promise => { + try { + const res = await axiosInstance.get('/members/mypage/dashboard-challenges'); + + return res.data as ReturnData; + } catch (error) { + console.error('Error fetching data:', error); + } +}; diff --git a/src/api/PersonalBlockApi.ts b/src/api/PersonalBlockApi.ts new file mode 100644 index 0000000..a6afeed --- /dev/null +++ b/src/api/PersonalBlockApi.ts @@ -0,0 +1,111 @@ +import axios from 'axios'; +import { BlockListResDto, BlockOrder } from '../types/PersonalBlock'; +import { axiosInstance } from '../utils/apiConfig'; + +// 블록 생성 post +export const createPersonalBlock = async (data: BlockListResDto): Promise => { + try { + const response = await axiosInstance.post('/blocks/', data); + console.log(response.data); + + return response.data.data.blockId; + } catch (error) { + console.error('Error fetching data:', error); + + return null; + } +}; + +// 블록 수정 patch +/* +! /blocks/뒤에 대시보드 id 들어가야 함 (지금은 임시로 1로 되어 있음) +*/ +export const patchPersonalBlock = async ( + blockId: string | undefined, + data: BlockListResDto +): Promise => { + try { + const response = await axiosInstance.patch(`/blocks/${blockId}`, data); + console.log(response.data); + + return response.data.data.blockId; + } catch (error) { + console.error('Error fetching data:', error); + + return null; + } +}; + +// 블록 확인 api +export const getPersonalBlock = async (blockId: string | null): Promise => { + try { + const response = await axiosInstance.get(`/blocks/${blockId}`); + // console.log(response.data.data); + return response.data.data; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +//블록의 상태 수정 +export const updatePersonalBlock = async (blockId?: string, progress?: string) => { + try { + const response = await axiosInstance.patch(`/blocks/${blockId}/progress?progress=${progress}`); + console.log(response); + return response.data; + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +//블록 순서 수정 +export const updateOrderBlock = async (data: BlockOrder) => { + try { + const response = await axiosInstance.patch(`/blocks/change`, data); + console.log(response); + return response.data; + } catch (error) { + console.log('Error fetching data:', error); + } +}; +//블록 삭제 조회 +export const getDeleteBlock = async (dashboardId: string, page?: number, size?: number) => { + try { + const response = await axiosInstance.get( + `/blocks/deleted?dashboardId=${dashboardId}&page=0&size=10` + ); + console.log(response); + return response.data.data; + } catch (error) { + console.log('Error fetching data:', error); + } +}; + +export const deleteBlock = async (blockId: string) => { + try { + const response = await axiosInstance.delete(`/blocks/${blockId}`); + + console.log(response); + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +export const realDeleteBlock = async (blockId: string) => { + try { + const response = await axiosInstance.delete(`/blocks/permanent/${blockId}`); + console.log(response); + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +export const restoreBlockFunc = async (blockId: string) => { + try { + const response = await axiosInstance.delete(`/blocks/${blockId}`); + console.log(response); + } catch (error) { + console.error('Error fetching data:', error); + } +}; diff --git a/src/api/TeamDashBoardApi.tsx b/src/api/TeamDashBoardApi.tsx new file mode 100644 index 0000000..a2c81bd --- /dev/null +++ b/src/api/TeamDashBoardApi.tsx @@ -0,0 +1,53 @@ +import { TeamDashboardInfoResDto, TeamDashboardResponse } from '../types/TeamDashBoard'; +import { axiosInstance } from '../utils/apiConfig'; + +// * 팀 대시보드 create +export const createTeamDashBoard = async ( + data: TeamDashboardInfoResDto +): Promise => { + try { + const response = await axiosInstance.post('/dashboards/team/', data); + return response.data.data.dashboardId; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 대시보드 patch +export const patchTeamDashBoard = async ( + dashboardId: string, + data: TeamDashboardInfoResDto +): Promise => { + try { + const response = await axiosInstance.patch(`/dashboards/team/${dashboardId}`, data); + console.log(response); + return response.data.data.dashboardId; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 대시보드 상세 정보 get +export const getTeamDashboard = async (id: string): Promise => { + try { + const response = await axiosInstance.get(`/dashboards/team/${id}`); + console.log(response); + return response.data.data; + } catch (error) { + console.log('error'); + return null; + } +}; + +// * 팀 대시보드 삭제 +export const deleteTeamDashboard = async (id: string): Promise => { + try { + const response = await axiosInstance.delete(`/dashboards/team/${id}`); + console.log(response); + } catch (error) { + console.log('error'); + // return null; + } +}; diff --git a/src/api/TeamDocumentApi.ts b/src/api/TeamDocumentApi.ts new file mode 100644 index 0000000..e71c7a1 --- /dev/null +++ b/src/api/TeamDocumentApi.ts @@ -0,0 +1,95 @@ +// import { BlockListResDto, BlockOrder } from '../types/PersonalBlock'; +import { axiosInstance } from '../utils/apiConfig'; + +// * 팀 문서 카테고리별 확인 api +export const getTeamDocument = async ( + teamDashboardId: string, + category: string | null, + page: number, + size: number +): Promise => { + try { + const response = await axiosInstance.get( + `/dashboards/team/document/search/${teamDashboardId}`, + { + params: { + category: category || '', // category가 null이면 쿼리에서 생략 + page: page, + size: size, + }, + } + ); + console.log('팀 문서 전체 데이터', response.data); + return response.data.data; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 문서 카테고리 확인 api +export const getTeamDocumentCategories = async ( + teamDashboardId: string +): Promise => { + try { + const response = await axiosInstance.get( + `/dashboards/team/document/categories/${teamDashboardId}` + ); + return response.data.data.categories; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 문서 생성 api +export const createTeamDocument = async (data: TeamDocument): Promise => { + try { + console.log('문서 생성할게요', data); + const response = await axiosInstance.post('/dashboards/team/document', data); + console.log('생성된 팀문서', response.data); + return response.data.data.teamDocumentId; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 문서 상세 확인 get +export const getTeamDocumentDetail = async ( + teamDocumentId: string +): Promise => { + try { + const response = await axiosInstance.get(`/dashboards/team/document/${teamDocumentId}`); + // console.log('팀 문서 상세 조회', response); + return response.data.data; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 문서 수정 patch +export const patchTeamDocument = async (data: TeamDocument): Promise => { + try { + console.log('수정할게요', data); + const response = await axiosInstance.patch( + `/dashboards/team/document/${data.teamDocumentId}`, + data + ); + console.log('수정된 팀문서', response.data); + return response.data.data.teamDocumentId; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 팀 문서 삭제 delete +export const deleteTeamDocument = async (teamDocumentId: string): Promise => { + try { + const response = await axiosInstance.delete(`/dashboards/team/document/${teamDocumentId}`); + } catch (error) { + console.error('Error fetching data:', error); + } +}; diff --git a/src/api/UserApi.ts b/src/api/UserApi.ts new file mode 100644 index 0000000..6114721 --- /dev/null +++ b/src/api/UserApi.ts @@ -0,0 +1,11 @@ +import { axiosInstance } from '../utils/apiConfig'; +import { UserInfo } from '../types/UserInfo'; + +export const userInfoApi = async (): Promise => { + try { + const response = await axiosInstance.get('/members/mypage'); + return response.data as UserInfo; + } catch (error) { + console.error('Error fetching data:', error); + } +}; diff --git a/src/components/Block.tsx b/src/components/Block.tsx new file mode 100644 index 0000000..18d7188 --- /dev/null +++ b/src/components/Block.tsx @@ -0,0 +1,138 @@ +import Flex from './Flex'; +import edit from '../img/edit.png'; +import deleteicon from '../img/delete.png'; +import * as S from '../styles/DashboardStyled'; +import { Draggable } from 'react-beautiful-dnd'; +import { useNavigate } from 'react-router-dom'; +import CustomModal from './CustomModal'; +import { dashboardType } from '../contexts/DashboardAtom'; +import { Link } from 'react-router-dom'; +import Profile from './Profile'; +import useModal from '../hooks/useModal'; +import { realDeleteBlock, restoreBlockFunc } from '../api/PersonalBlockApi'; +import { useEffect, useState } from 'react'; +import { useAtom } from 'jotai'; +import { fetchTriggerAtom } from '../contexts/atoms'; + +type Props = { + blockId: string | null | undefined; + title: string | undefined; + index: number; + contents: string | undefined; + dDay: number | undefined; + dashboardId: string | undefined; + remove?: boolean; + onBlockIdHandler?: (num: string) => void; + removeValue?: boolean; + dType: string | undefined; + name: string | undefined; + picture?: string; +}; + +const Block = ({ + title, + index, + blockId, + contents, + dDay, + remove = false, + onBlockIdHandler, + removeValue, + dType, + name, + picture, +}: Props) => { + const [isRemove, setIsRemove] = useState(true); + const { isModalOpen, openModal, handleYesClick, handleNoClick } = useModal(); + const updatedBlockId = blockId ? (parseInt(blockId, 10) + 1).toString() : '1'; + const [, setFetchTrigger] = useAtom(fetchTriggerAtom); // 트리거 업데이트 함수 가져오기 + const navigate = useNavigate(); + + const clickHandler = () => { + navigate(`personalBlock/${blockId}`); + }; + const removeFunc = async () => { + if (blockId) { + await realDeleteBlock(blockId); // 블록을 삭제하는 API 호출 + setFetchTrigger(prev => prev + 1); // 상태를 변경하여 MainPage에서 데이터를 다시 불러오도록 트리거 + } + }; + + //모달 복구에 전달할 함수 + const restoreFunc = async () => { + if (blockId) await restoreBlockFunc(blockId); + setFetchTrigger(prev => prev + 1); // 상태를 변경하여 MainPage에서 데이터를 다시 불러오도록 트리거 + }; + + //완전 삭제 로직 + const onremoveHandler = () => { + setIsRemove(true); + openModal('yes', removeFunc); + }; + + //복구 로직 + const onRestoreFunc = () => { + setIsRemove(false); + openModal('yes', restoreFunc); + }; + + return ( + <> + + {provided => ( + <> + { + if (!removeValue) { + clickHandler(); + } + }} + > + +

{title}

+ D-{dDay} +
+ {dType === 'PersonalDashboard' ? ( +

{contents}

+ ) : ( + + + + + {name} + + )} +
+ {removeValue && ( + + + + + )} + + )} +
+ {isModalOpen && isRemove && ( + + )} + {isModalOpen && !isRemove && ( + + )} + + ); +}; +export default Block; diff --git a/src/components/ChallengeBlock.tsx b/src/components/ChallengeBlock.tsx new file mode 100644 index 0000000..adba763 --- /dev/null +++ b/src/components/ChallengeBlock.tsx @@ -0,0 +1,17 @@ +import * as S from '../styles/MyPageStyled'; + +const ChallengeBlock = () => { + return ( + + 대시보드 이름 + + 주기 +  |  + 참여인원 +
+

챌린지 설명

+
+
+ ); +}; +export default ChallengeBlock; diff --git a/src/components/CompletedDashboard.tsx b/src/components/CompletedDashboard.tsx new file mode 100644 index 0000000..0270c81 --- /dev/null +++ b/src/components/CompletedDashboard.tsx @@ -0,0 +1,92 @@ +import { useNavigate, Outlet } from 'react-router-dom'; +import Block from './Block'; +import * as S from '../styles/DashboardStyled'; +import { createPersonalBlock } from '../api/PersonalBlockApi'; +import SidePage from '../pages/SidePage'; +import { Droppable } from 'react-beautiful-dnd'; +import theme from '../styles/Theme/Theme'; +import main2 from '../img/main2.png'; +import { BlockListResDto } from '../types/PersonalBlock'; + +type Props = { + // list: StatusPersonalBlock | undefined; + list: BlockListResDto[]; + id: string; + dashboardId: string; +}; + +const CompletedDashboard = ({ list, id, dashboardId }: Props) => { + const navigate = useNavigate(); + // const blocks = list.flatMap((item: StatusPersonalBlock) => item.blockListResDto); + // const blocks = list?.blockListResDto; + + const settings = { + backGroundColor: '#F7F1FF', + highlightColor: theme.color.main2, + progress: '완료', + imgSrc: main2, + }; + + // + 버튼 누르면 사이드 페이지로 이동 + const handleAddBtn = async () => { + // 초기 post 요청은 빈 내용으로 요청. 추후 patch로 자동 저장. + const now = new Date(); + const startDate = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} 00:00`; + const deadLine = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} 23:59`; + + const data = { + dashboardId: dashboardId, + title: '', + contents: '', + progress: 'COMPLETED', + startDate: startDate, + deadLine: deadLine, + }; + + const blockId = await createPersonalBlock(data); + // console.log(blockId); + + const { highlightColor, progress } = settings; + navigate(`personalBlock/${blockId}`, { state: { highlightColor, progress, blockId } }); + }; + + return ( + +
+ + {settings.progress} + + + 블록 더하는 버튼 + +
+ + {provided => ( + + {list?.map((block, index) => ( + + ))} + {provided.placeholder} + + )} + + +
+ ); +}; +export default CompletedDashboard; diff --git a/src/components/CustomModal.tsx b/src/components/CustomModal.tsx new file mode 100644 index 0000000..524ba13 --- /dev/null +++ b/src/components/CustomModal.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import ErrorIcon from '../img/error.png'; +import Flex from './Flex'; +import { + StyledModal, + customStyles, + ErrorImg, + SubTitle, + Title, + BtnYes, + BtnNo, +} from '../styles/ModalStyled'; +import { CustomModalProps } from '../types/CustomModal'; + +const CustomModal = ({ title, subTitle, onYesClick, onNoClick }: CustomModalProps) => { + return ( + + + + {subTitle} + {title} + + + + 아니오 + + + ); +}; + +export default CustomModal; diff --git a/src/components/CustomPagination.tsx b/src/components/CustomPagination.tsx new file mode 100644 index 0000000..b6b4025 --- /dev/null +++ b/src/components/CustomPagination.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import Pagination from '@mui/material/Pagination'; +import theme from '../styles/Theme/Theme'; + +interface BasicPaginationProps { + count: number; // 총 페이지 수 + page: number; // 현재 페이지 번호 + onChange: (event: React.ChangeEvent, page: number) => void; // 페이지 변경 이벤트 핸들러 +} + +const CustomPagination: React.FC = ({ count, page, onChange }) => { + return ( + + ); +}; + +export default CustomPagination; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 0000000..0b8e78d --- /dev/null +++ b/src/components/Dashboard.tsx @@ -0,0 +1,88 @@ +import { useNavigate, useLocation } from 'react-router-dom'; +// import { getPersonalBlock } from '../api/BoardApi'; +import * as S from '../styles/DashboardStyled'; +import { DashboardItem } from '../types/PersonalDashBoard'; +import { TeamDashboardInfoResDto } from '../types/TeamDashBoard'; +import { useEffect, useState } from 'react'; + +interface Props { + text: string; + dashboard?: DashboardItem[] | TeamDashboardInfoResDto[]; +} + +interface GroupedDashboards { + [category: string]: DashboardItem[]; +} + +const Dashboard = ({ text, dashboard }: Props) => { + const navigate = useNavigate(); + const location = useLocation(); + const id = location.pathname.split('/')[1]; + const [groupedDashboard, setGroupedDashboard] = useState({}); + + const onClick = (id: number | string) => { + navigate(`/${id}`); + localStorage.setItem('LatestBoard', String(id)); + }; + + const groupByCategory = (dashboardItems: DashboardItem[] | undefined): GroupedDashboards => { + if (!dashboardItems) { + return {}; // undefined일 경우 빈 객체 반환 + } + + return dashboardItems.reduce((acc, item) => { + const category = item.category; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(item); + return acc; + }, {} as GroupedDashboards); + }; + + useEffect(() => { + if (dashboard) { + const grouped = groupByCategory(dashboard as DashboardItem[]); + setGroupedDashboard(grouped); + } + }, [dashboard]); + + return ( + +
{text} 대시보드
+ {text === '팀' ? ( + // 팀 대시보드일 때 렌더링 + + {dashboard?.map((value, index) => ( + { + onClick(value.dashboardId ?? 0); + }} + > + {value.title} + + ))} + + ) : ( + // 개인 대시보드일 때 렌더링 + Object.keys(groupedDashboard).map(category => ( +
+ {category} + + {groupedDashboard[category].map(dashboardItem => ( + onClick(dashboardItem.dashboardId ?? 0)} + > + {dashboardItem.title} + + ))} + +
+ )) + )} +
+ ); +}; +export default Dashboard; diff --git a/src/components/DashboardCard.tsx b/src/components/DashboardCard.tsx new file mode 100644 index 0000000..8c7c2c9 --- /dev/null +++ b/src/components/DashboardCard.tsx @@ -0,0 +1,53 @@ +import { useNavigate, Outlet } from 'react-router-dom'; +import Block from './Block'; +import * as S from '../styles/DashboardStyled'; +import { useAtom } from 'jotai'; +import { visibleAtom } from '../contexts/sideScreenAtom'; +import SidePage from '../pages/SidePage'; + +type Props = { + backGroundColor?: string; + highlightColor?: string; + progress?: string; + imgSrc?: string; +}; + +const DashboardCard = ({ backGroundColor, highlightColor, progress, imgSrc }: Props) => { + const navigate = useNavigate(); + const [visibleValue, _] = useAtom(visibleAtom); + + const handleAddBtn = () => { + console.log(progress); + navigate(`/side/1`, { state: { highlightColor, progress } }); + }; + + return ( + +
+ + {progress} + + + 블록 더하는 버튼 + +
+
{/* */}
+ +
+ ); +}; +export default DashboardCard; + +/* +* 블록 생성시 필요한 정보 +{ + "dashboardId" : 1, + "title" : "Title", + "contents" : "Contents", + "progress" : "NOT_STARTED", + "deadLine" : "2024.08.03 13:23" +} + +! 플러스 버튼을 눌렀을때 빈 title, contents, deadLine이 들어간 post 요청을 보내고, +! 사이드 페이지에서 1분 마다 자동 저장 + 다른 페이지로 넘어가면 자동 저장은 patch 요청 +*/ diff --git a/src/components/DeleteButton.tsx b/src/components/DeleteButton.tsx new file mode 100644 index 0000000..dd39a32 --- /dev/null +++ b/src/components/DeleteButton.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import deleteicon from '../img/delete2.png'; +import * as S from '../styles/MainPageStyled'; +import { Droppable } from 'react-beautiful-dnd'; +import Block from './Block'; +import { BlockListResDto } from '../types/PersonalBlock'; +import CustomModal from './CustomModal'; +import useModal from '../hooks/useModal'; + +interface Props { + id: string; + list: BlockListResDto[]; + removeValue: boolean; +} +const DeleteButton = ({ id, list, removeValue }: Props) => { + const [value, setValue] = useState(false); + const [blockId, setBlockId] = useState(''); + + const onValueFunction = () => { + setValue(value => !value); + }; + + const onBlockIdHandler = (num: string | null | undefined) => { + if (num) setBlockId(num); + }; + return ( + + {value && ( + + {provided => ( + +

휴지통

+ + {list.map( + ( + { title, blockId, contents, dDay, dashboardId, dType, nickname, picture }, + index + ) => ( + + + + ) + )} + + {provided.placeholder} +
+ )} +
+ )} + + 휴지통아이콘 + + {/* {isModalOpen && ( + + )} */} +
+ ); +}; +export default DeleteButton; diff --git a/src/components/DocumentCard.tsx b/src/components/DocumentCard.tsx new file mode 100644 index 0000000..884da5f --- /dev/null +++ b/src/components/DocumentCard.tsx @@ -0,0 +1,33 @@ +import { Link } from 'react-router-dom'; +import Flex from '../components/Flex'; +import * as S from '../styles/TeamDocumentStyled'; + +type Props = { + // documentId: string | undefined; + // title?: string; + // category?: string; + // name?: string; + // profileImg?: string; + // content?: string; + document: TeamDocument; +}; + +const DocumentCard = ({ document }: Props) => { + return ( + + + + {document.category} + {document.title} + + + 프로필 사진 + + {document.author} + + + + + ); +}; +export default DocumentCard; diff --git a/src/components/File.tsx b/src/components/File.tsx new file mode 100644 index 0000000..f854302 --- /dev/null +++ b/src/components/File.tsx @@ -0,0 +1,24 @@ +import fileimg from '../img/fileimg.png'; +import Flex from './Flex'; +import * as S from '../styles/TeamDocumentStyled'; +import { visibleAtom } from '../contexts/sideScreenAtom'; +import { useAtom } from 'jotai'; +type Props = { + caption: string; +}; +const File = ({ caption }: Props) => { + const [_, setVisibleValue] = useAtom(visibleAtom); + + const toggleFunc = () => { + setVisibleValue(prev => !prev); + }; + return ( + + + 파일 이모티콘 + {caption} + + + ); +}; +export default File; diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx new file mode 100644 index 0000000..84e7644 --- /dev/null +++ b/src/components/Flex.tsx @@ -0,0 +1,64 @@ +import { CSSProperties, ComponentPropsWithoutRef, ReactNode } from 'react'; + +import styled from 'styled-components'; + +interface FlexProps extends ComponentPropsWithoutRef<'div'> { + children?: ReactNode; + flexDirection?: CSSProperties['flexDirection']; + justifyContent?: CSSProperties['justifyContent']; + alignItems?: CSSProperties['alignItems']; + gap?: CSSProperties['gap']; + width?: CSSProperties['width']; + height?: CSSProperties['height']; + margin?: CSSProperties['margin']; + padding?: CSSProperties['padding']; + flexWrap?: CSSProperties['flexWrap']; + flexGrow?: CSSProperties['flexGrow']; +} + +const StyledFlex = styled.div` + display: flex; + flex-direction: ${({ flexDirection }) => flexDirection}; + align-items: ${({ alignItems }) => alignItems}; + justify-content: ${({ justifyContent }) => justifyContent}; + gap: ${({ gap }) => (typeof gap === 'number' ? `${gap}px` : gap)}; + width: ${({ width }) => (typeof width === 'number' ? `${width}px` : (width ?? 'auto'))}; + height: ${({ height }) => (typeof height === 'number' ? `${height}px` : (height ?? 'auto'))}; + margin: ${({ margin }) => margin}; + padding: ${({ padding }) => padding}; + flex-wrap: ${({ flexWrap }) => flexWrap}; + flex-grow: ${({ flexGrow }) => flexGrow}; +`; + +export default function Flex({ + children, + flexDirection = 'row', + justifyContent = 'flex-start', + alignItems = 'center', + gap = '0px', + width, + height, + flexGrow, + margin, + padding, + flexWrap, + ...rest +}: FlexProps) { + return ( + + {children} + + ); +} diff --git a/src/components/Folder.tsx b/src/components/Folder.tsx new file mode 100644 index 0000000..f3a206b --- /dev/null +++ b/src/components/Folder.tsx @@ -0,0 +1,25 @@ +import folderimg from '../img/folderimg.png'; +import Flex from './Flex'; +import { useNavigate } from 'react-router-dom'; +import * as S from '../styles/TeamDocumentStyled'; + +type Props = { + caption: string; +}; +const Folder = ({ caption }: Props) => { + const navigate = useNavigate(); + + const handleNavigate = () => { + navigate('1'); // 상대 경로로 이동 `1`은 추후에 서버에서 받아오는 id값으로 대체할 예정임 + }; + + return ( + + + 폴더 이미지 + {caption} + + + ); +}; +export default Folder; diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx new file mode 100644 index 0000000..92dc767 --- /dev/null +++ b/src/components/Graph.tsx @@ -0,0 +1,16 @@ +import * as S from '../styles/DashboardStyled'; + +type GraphProps = { + blockProgress: number; +}; + +const Graph = ({ blockProgress }: GraphProps) => { + return ( + + +

{blockProgress}%

+
+
+ ); +}; +export default Graph; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..a73fee7 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,70 @@ +import Graph from '../components/Graph'; +import Flex from './Flex'; +import setting from '../img/setting.png'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import leftarrow from '../img/leftarrow.png'; +import * as S from '../styles/HeaderStyled'; +import { dashboardType } from '../contexts/DashboardAtom'; + +type Props = { + mainTitle: string; + subTitle: string; + blockProgress: number; + dashboardType?: boolean; +}; + +const Header = ({ mainTitle, subTitle, blockProgress, dashboardType }: Props) => { + const navigate = useNavigate(); + const location = useLocation(); + const dashboardId = location.pathname.split('/')[1]; + + const handleBackClick = () => { + navigate(-1); + }; + + // URL에 "teamdocument"가 포함되어 있는지 확인하는 함수 + // => 전역 변수로 개인 대시보드인지 팀 대시보드인지 확인할 예정이라 주석 처리 + // const teamLocationUrl = location.pathname.includes('teamdocument') ?? true; + return ( + <> + + + + {/* {!dashboardType && ( + + + + )} */} +
+ + {mainTitle} + + {dashboardType ? ( + + 설정 이미지 + + ) : ( + + 설정 이미지 + + )} + + + {subTitle} +
+
+
+ + + {!dashboardType && ( + + 팀문서 + + )} + +
+
+ + ); +}; +export default Header; diff --git a/src/components/InProgressDashboard.tsx b/src/components/InProgressDashboard.tsx new file mode 100644 index 0000000..ba1f4c9 --- /dev/null +++ b/src/components/InProgressDashboard.tsx @@ -0,0 +1,92 @@ +import { useNavigate, Outlet } from 'react-router-dom'; +import Block from './Block'; +import * as S from '../styles/DashboardStyled'; +import { createPersonalBlock } from '../api/PersonalBlockApi'; +import SidePage from '../pages/SidePage'; +import { Droppable } from 'react-beautiful-dnd'; +import theme from '../styles/Theme/Theme'; +import main from '../img/main.png'; +import { BlockListResDto } from '../types/PersonalBlock'; + +type Props = { + // list: StatusPersonalBlock | undefined; + list: BlockListResDto[]; + id: string; + dashboardId: string; +}; + +const InProgressDashboard = ({ list, id, dashboardId }: Props) => { + const navigate = useNavigate(); + // const blocks = list.flatMap((item: StatusPersonalBlock) => item.blockListResDto); + // const blocks = list?.blockListResDto; + + const settings = { + backGroundColor: '#EDF3FF', + highlightColor: theme.color.main, + progress: '진행 중', + imgSrc: main, + }; + + // + 버튼 누르면 사이드 페이지로 이동 + const handleAddBtn = async () => { + // 초기 post 요청은 빈 내용으로 요청. 추후 patch로 자동 저장. + const now = new Date(); + const startDate = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} 00:00`; + const deadLine = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} 23:59`; + + const data = { + dashboardId: dashboardId, + title: '', + contents: '', + progress: 'IN_PROGRESS', + startDate: startDate, + deadLine: deadLine, + }; + + const blockId = await createPersonalBlock(data); + // console.log(blockId); + + const { highlightColor, progress } = settings; + navigate(`personalBlock/${blockId}`, { state: { highlightColor, progress, blockId } }); + }; + + return ( + +
+ + {settings.progress} + + + 블록 더하는 버튼 + +
+ + {provided => ( + + {list?.map((block, index) => ( + + ))} + {provided.placeholder} + + )} + + +
+ ); +}; +export default InProgressDashboard; diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..69e0dce --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,111 @@ +import React, { useEffect } from 'react'; +import { Container } from '../styles/LoadingStyled'; +import { useNavigate } from 'react-router-dom'; +import Qudy from '../image/Qtudy_char.png'; +import axios from 'axios'; + +const Loading = () => { + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; +export default Loading; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..f754e3d --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router-dom'; +import rightarrow from '../img/rightarrow.png'; +import * as S from '../styles/NavBarStyled'; +import Dashboard from './Dashboard'; +import Profile from './Profile'; +import { usePersonalDashBoardSearch } from '../hooks/usePersonalDashBoard'; +import useTeamDashBoard from '../hooks/useTeamDashBoard'; +import useInfo from '../hooks/useInfo'; + +const Navbar = () => { + const { dashboard } = usePersonalDashBoardSearch(); + const { teamDashboard } = useTeamDashBoard(); + const { info } = useInfo(); + + return ( + +
+ + + +

{info?.data.nickName}

+ + + 마이페이지 마이페이지 이동 화살표 + +
+
+ + + 대시보드 생성 + + 도전! 챌린지 + +
+ + + + +
+ ); +}; +export default Navbar; diff --git a/src/components/NotStartedDashboard.tsx b/src/components/NotStartedDashboard.tsx new file mode 100644 index 0000000..e8f0834 --- /dev/null +++ b/src/components/NotStartedDashboard.tsx @@ -0,0 +1,123 @@ +import { useNavigate, Outlet } from 'react-router-dom'; +import Block from './Block'; +import * as S from '../styles/DashboardStyled'; +import { createPersonalBlock } from '../api/PersonalBlockApi'; +import { useAtom } from 'jotai'; +import { visibleAtom } from '../contexts/sideScreenAtom'; +import SidePage from '../pages/SidePage'; +import { Droppable } from 'react-beautiful-dnd'; +import theme from '../styles/Theme/Theme'; +import main3 from '../img/main3.png'; +import { BlockListResDto, StatusPersonalBlock } from '../types/PersonalBlock'; +import { useInView } from 'react-intersection-observer'; +import { useEffect, useState } from 'react'; + +type Props = { + // list: StatusPersonalBlock | undefined; + list: BlockListResDto[]; + id: string; + dashboardId: string; + onLoadMore: () => void; +}; + +const NotStartedDashboard = ({ list, id, dashboardId, onLoadMore }: Props) => { + const navigate = useNavigate(); + const settings = { + backGroundColor: '#E8FBFF', + highlightColor: theme.color.main3, + progress: '시작 전', + imgSrc: main3, + }; + + // + 버튼 누르면 사이드 페이지로 이동 + const handleAddBtn = async () => { + // 초기 post 요청은 빈 내용으로 요청. 추후 patch로 자동 저장. + const now = new Date(); + const startDate = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} 00:00`; + const deadLine = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} 23:59`; + + const data = { + dashboardId: dashboardId, + title: '', + contents: '', + progress: 'NOT_STARTED', + startDate: startDate, + deadLine: deadLine, + }; + + const blockId = await createPersonalBlock(data); + + const { highlightColor, progress } = settings; + navigate(`personalBlock/${blockId}`, { + state: { highlightColor, progress, blockId }, + }); + }; + + // 세로 무한 스크롤 + const { ref: lastBlockRef, inView } = useInView({ + threshold: 0, // 마지막 블록이 0% 보였을 때를 감지 + }); + + useEffect(() => { + if (inView) { + onLoadMore(); // 부모 컴포넌트에 새로운 데이터 요청 + } + }, [inView]); + + // todo: 다시 렌더링 됐을 때 이전 스크롤 위치를 기억했다가 그대로 보여줘야함 + + return ( + +
+ + {settings.progress} + + + 블록 더하는 버튼 + +
+ + {provided => ( + + {/* {list?.map((block, index) => ( + + ))} */} + {list?.map((block, index) => { + const isLastBlock = index === list.length - 1; + return ( +
+ +
+ ); + })} + {provided.placeholder} +
+ )} +
+ +
+ ); +}; +export default NotStartedDashboard; diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx new file mode 100644 index 0000000..39fe3e6 --- /dev/null +++ b/src/components/Profile.tsx @@ -0,0 +1,15 @@ +import * as S from '../styles/ProfileStyled'; + +export type Props = { + width?: string; + height?: string; + profile?: string; +}; +const Profile = ({ width, height, profile }: Props) => { + return ( + + 프로필 이미지 + + ); +}; +export default Profile; diff --git a/src/components/SidebarScreen.tsx b/src/components/SidebarScreen.tsx new file mode 100644 index 0000000..561bf83 --- /dev/null +++ b/src/components/SidebarScreen.tsx @@ -0,0 +1,61 @@ +import Profile from './Profile'; +import Flex from './Flex'; +import edit from '../img/edit.png'; +import trash from '../img/delete2.png'; +import { visibleAtom } from '../contexts/sideScreenAtom'; +import { useAtom } from 'jotai'; +import closebutton from '../img/closebutton.png'; +import { useRef, useState } from 'react'; +import * as S from '../styles/SideScreenStyled'; + +const SidebarScreen = () => { + const [inputText, setInputText] = useState(''); + const [_, setVisibleValue] = useAtom(visibleAtom); + + const editorRef = useRef(null); + + //사이드 스크린 상태 변수 변경 함수 + const toggleFunc = (event: React.MouseEvent) => { + setVisibleValue(prev => !prev); + event.stopPropagation(); + }; + + const onInputHandler = (event: React.SyntheticEvent) => { + setInputText((event.target as HTMLDivElement).innerText); + }; + + return ( + + { + e.stopPropagation(); + }} + > + + 닫기 버튼 + +
+ + + 김신아 + + + + + 편집 버튼 + 휴지통 버튼 + + +
+
+ + +
+
+ ); +}; +export default SidebarScreen; diff --git a/src/components/ThreeScene.tsx b/src/components/ThreeScene.tsx new file mode 100644 index 0000000..5a4dce5 --- /dev/null +++ b/src/components/ThreeScene.tsx @@ -0,0 +1,82 @@ +import React, { useRef, useEffect } from 'react'; +import * as THREE from 'three'; +// import { GlobalStyle, CanvasContainer } from '../styles/ThreeSceneStyled'; +import middle from '../img/kkeujeok_middle.png'; +import out from '../img/kkeujeok_out.png'; + +const ThreeScene: React.FC = () => { + const mountRef = useRef(null); + + useEffect(() => { + if (!mountRef.current) return; + + const width = window.innerWidth / 1.5; + const height = window.innerWidth / 1.5; + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-10, 10, 10, -10, -10, 10); + const renderer = new THREE.WebGLRenderer({ alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(2); + + mountRef.current.appendChild(renderer.domElement); + + const texture1 = new THREE.TextureLoader().load(middle); + const texture2 = new THREE.TextureLoader().load(out); + + const material1 = new THREE.MeshBasicMaterial({ map: texture1, transparent: true }); + const material2 = new THREE.MeshBasicMaterial({ map: texture2, transparent: true }); + + const geometry1 = new THREE.SphereGeometry(9.98, 50, 50); + const geometry2 = new THREE.SphereGeometry(10, 50, 50); + + const mesh1 = new THREE.Mesh(geometry1, material1); + const mesh2 = new THREE.Mesh(geometry2, material2); + + mesh2.rotation.y = -Math.PI / 2; + mesh1.rotation.y = -Math.PI / 2; + + scene.add(mesh1); + scene.add(mesh2); + + const animate = () => { + requestAnimationFrame(animate); + mesh1.rotation.y += 0.0009; + mesh2.rotation.y -= 0.0009; + renderer.render(scene, camera); + }; + + animate(); + + const handleMouseMove = (e: MouseEvent | TouchEvent) => { + const isTouch = e.type.startsWith('touch'); + const clientX = isTouch ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX; + const clientY = isTouch ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY; + + const pos = (((360 * (clientX - width / 2)) / width) * Math.PI) / 180 / 2 - Math.PI / 2; + const pos2 = (((360 * (clientY - height / 8)) / height) * Math.PI) / 180 - Math.PI / 2; + + mesh2.rotation.y = -pos - Math.PI; + mesh1.rotation.y = pos; + mesh2.rotation.x = pos2 / 10; + mesh1.rotation.x = pos2 / 10; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('touchmove', handleMouseMove); + window.addEventListener('touchstart', handleMouseMove); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('touchmove', handleMouseMove); + window.removeEventListener('touchstart', handleMouseMove); + if (mountRef.current) { + mountRef.current.removeChild(renderer.domElement); + } + }; + }, []); + + return
; +}; + +export default ThreeScene; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..333a61a --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,90 @@ +import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react'; +import { axiosInstance } from '../utils/apiConfig'; + +// 유저 정보 타입 정의 +interface UserInfo { + id: string; + name: string; + email: string; +} + +// AuthContext 타입 정의 +interface AuthContextType { + userInfo: UserInfo | null; + login: ({ accessToken, refreshToken }: { accessToken: string; refreshToken: string }) => void; + logout: () => void; +} + +// 초기 값 정의 +const AuthContext = createContext(undefined); + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [userInfo, setUserInfo] = useState(null); + + useEffect(() => { + const token = localStorage.getItem('accessToken'); + if (token) { + fetchMemberInfo(); + } else { + setUserInfo(null); + } + }, []); + + const fetchMemberInfo = async () => { + try { + const response = await axiosInstance.get(`${process.env.REACT_APP_API_BASE_URL}/member/info`); + console.log(response); + setUserInfo(response.data.data); + } catch (error) { + console.error('유저 정보를 가져오는데 실패했습니다.', error); + setUserInfo(null); + } + }; + + const fetchLogout = async () => { + try { + await axiosInstance.post(`/logout`); + } catch (error) { + console.error('로그아웃 실패', error); + } + }; + + const login = ({ accessToken, refreshToken }: { accessToken: string; refreshToken: string }) => { + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + // fetchMemberInfo(); + }; + + const logout = async () => { + const isConfirmed = window.confirm('정말 로그아웃 하시겠습니까?'); + if (isConfirmed) { + await fetchLogout(); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setUserInfo(null); + alert('로그아웃 성공!'); + } else { + alert('로그아웃 취소'); + } + }; + + const value: AuthContextType = { + userInfo, + login, + logout, + }; + + return {children}; +}; diff --git a/src/contexts/DashboardAtom.ts b/src/contexts/DashboardAtom.ts new file mode 100644 index 0000000..7da18ad --- /dev/null +++ b/src/contexts/DashboardAtom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const dashboardType = atom(false); // true: 개인, false: 팀 diff --git a/src/contexts/OAuthRedirectHandler.tsx b/src/contexts/OAuthRedirectHandler.tsx new file mode 100644 index 0000000..3eaa67f --- /dev/null +++ b/src/contexts/OAuthRedirectHandler.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import Loading from '../components/Loading'; + +interface LoginToken { + accessToken: string; + refreshToken: string; +} + +const OAuthRedirectHandler = () => { + const { provider } = useParams(); // Get the provider from the route parameters + const navigate = useNavigate(); + const { login } = useAuth(); + const [loginToken, setLoginToken] = useState({ + accessToken: '', + refreshToken: '', + }); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + + if (code && provider) { + getToken(code, provider); + } + }, [provider]); + + useEffect(() => { + if (loginToken.accessToken) { + login(loginToken); + navigate('/'); // 기본 대시보드 불러올 라우터로 설정 (가장 마지막에 방문한 대시보드를 기준으로) + } + }, [loginToken, login, navigate]); + + const getToken = async (authCode: string, provider: string) => { + try { + const idTokenResponse = await axios.get( + `${process.env.REACT_APP_API_BASE_URL}/oauth2/callback/${provider}?code=${authCode}` + ); + + const tokenResponse = await axios.post( + `${process.env.REACT_APP_API_BASE_URL}/${provider}/token`, + { + authCode: idTokenResponse.data.idToken, + } + ); + + if (tokenResponse.data.data) { + setLoginToken(tokenResponse.data.data); + } + } catch (error) { + console.error('토큰을 가져오는데 실패했습니다.', error); + } + }; + + return ; +}; + +export default OAuthRedirectHandler; diff --git a/src/contexts/atoms.ts b/src/contexts/atoms.ts new file mode 100644 index 0000000..d7bd1e7 --- /dev/null +++ b/src/contexts/atoms.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; + +// 데이터를 다시 불러오는 트리거 상태를 관리할 atom +export const fetchTriggerAtom = atom(0); diff --git a/src/contexts/sideScreenAtom.ts b/src/contexts/sideScreenAtom.ts new file mode 100644 index 0000000..a3bb01f --- /dev/null +++ b/src/contexts/sideScreenAtom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const visibleAtom = atom(false); diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..2b5a204 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react'; + +// 제네릭으로 모든 타입을 받을 수 있는 debounce 훅 +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/hooks/useInfo.ts b/src/hooks/useInfo.ts new file mode 100644 index 0000000..955256a --- /dev/null +++ b/src/hooks/useInfo.ts @@ -0,0 +1,28 @@ +/* + * 사용자 정보 훅 + */ + +import { useEffect, useState } from 'react'; +import { UserInfo } from '../types/UserInfo'; +import { userInfoApi } from '../api/UserApi'; + +const useInfo = () => { + const [info, setInfo] = useState(); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await userInfoApi(); // 서버에서 데이터를 받아옵니다. + setInfo(data); // 받아온 데이터를 상태에 저장합니다. + } catch (error) { + console.log(error); + } + }; + + fetchData(); + }, []); + + return { info }; +}; + +export default useInfo; diff --git a/src/hooks/useInterval.ts b/src/hooks/useInterval.ts new file mode 100644 index 0000000..d7ef05c --- /dev/null +++ b/src/hooks/useInterval.ts @@ -0,0 +1,34 @@ +/* +! 임시 방편으로 eslint 꺼둠. any 타입 설정 후 삭제할 코드 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useRef } from 'react'; + +type Delay = number | null; +type TimerHandler = (...args: any[]) => void; + +/** + * Provides a declarative useInterval + * + * @param callback - Function that will be called every `delay` ms. + * @param delay - Number representing the delay in ms. Set to `null` to "pause" the interval. + */ + +const useInterval = (callback: TimerHandler, delay: Delay) => { + const savedCallbackRef = useRef(); + + useEffect(() => { + savedCallbackRef.current = callback; + }, [callback]); + + useEffect(() => { + const handler = (...args: any[]) => savedCallbackRef.current!(...args); + + if (delay !== null) { + const intervalId = setInterval(handler, delay); + return () => clearInterval(intervalId); + } + }, [delay]); +}; + +export default useInterval; diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx new file mode 100644 index 0000000..6f060ba --- /dev/null +++ b/src/hooks/useModal.tsx @@ -0,0 +1,86 @@ +/* +* 모달창 사용법 +1) 비즈니스 로직 +openModal()에 원하는 버튼과 원하는 함수를 최대 2쌍 (즉 총 인수 4개) 넣을 수 있음 +예를 들어, yes 버튼이 눌렸을때만 작동할 함수는 openModal('yes', 함수이름) +그리고 각각 다른 함수를 하고 싶다면 openModal('yes', 함수이름1, 'no', 함수이름2) +만약 특별한 함수 없이 단순 정보를 보여주기 위한 모달이라면 openModal(normal)로 어떤 버튼을 누르든 모달창이 꺼지도록 호출 가능 + +2) 모달창 컴포넌트 (모달창 조건과 title, subTitle만 변경. 나머지 고정) + {isModalOpen && isEmptyModalOpen && ( + + )} + +3) 모달창 조건 관리 : 비즈니스 로직에서 + setIsEmptyModalOpen(true); +const handleModalClose = () => setIsEmptyModalOpen(false); +openModal('normal', handleModalClose); +2번과 같은 모달창을 여러 조건에 따라 띄워야 한다면 조건을 on/off할 수 있도록 상태 함수를 콜백으로 전달 받는다. +그리고 openModal()함수가 종료됨에 따라 해당 조건을 다시 off로 바꿔, 모달창 여러 개가 취소되더라도 섞이지 않도록 한다. +*/ +import { useState } from 'react'; + +type ModalAction = 'yes' | 'no' | 'normal'; + +export const useModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [actionHandlers, setActionHandlers] = useState<{ + [key in ModalAction]: (() => void) | null; + }>({ + yes: null, + no: null, + normal: null, + }); + + // 콜백 함수 추가 + const [onCloseCallback, setOnCloseCallback] = useState<(() => void) | null>(null); + + const openModal = ( + action: ModalAction, + handler?: () => void, + noHandler?: () => void, + closeCallback?: () => void + ) => { + if (action === 'yes') { + setActionHandlers({ yes: handler || null, no: noHandler || null, normal: noHandler || null }); + } else if (action === 'no') { + setActionHandlers({ yes: noHandler || null, no: handler || null, normal: noHandler || null }); + } else if (action === 'normal') { + setActionHandlers({ + yes: noHandler || null, + no: noHandler || null, + normal: noHandler || null, + }); + } + setOnCloseCallback(closeCallback || null); // 콜백 함수 설정 + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + if (onCloseCallback) { + onCloseCallback(); // 모달이 닫힐 때 콜백 호출 (여러 모달창이 있을 때 조건 관리를 위한 콜백 함수) + } + }; + + const handleYesClick = async () => { + if (actionHandlers.yes) { + await actionHandlers.yes(); // 사용자 정의 yes 클릭 핸들러 실행 + } + closeModal(); // 모달을 닫음 + }; + + const handleNoClick = () => { + if (actionHandlers.no) actionHandlers.no(); // 사용자 정의 no 클릭 핸들러 실행 + closeModal(); // 모달을 닫음 + }; + + return { isModalOpen, openModal, handleYesClick, handleNoClick }; +}; + +export default useModal; diff --git a/src/hooks/usePersonalDashBoard.tsx b/src/hooks/usePersonalDashBoard.tsx new file mode 100644 index 0000000..65d156f --- /dev/null +++ b/src/hooks/usePersonalDashBoard.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + createDashBoard, + deletePersonalDashboard, + getPersonalDashboard, + patchDashBoard, +} from '../api/BoardApi'; +import { + DashboardItem, + // PersonalDashBoard, + PersonalSearchDashBoard, +} from '../types/PersonalDashBoard'; +import { searchPersonalDashBoard, getCategories } from '../api/BoardApi'; +import useModal from './useModal'; + +/* + * 개인 대시보드 생성 커스텀 훅 + */ +const usePersonalDashBoard = (dashboardId: string | null) => { + const [formData, setFormData] = useState({ + title: '', + description: '', + isPublic: false, + category: '', + }); + const { isModalOpen, openModal, handleYesClick, handleNoClick } = useModal(); // 모달창 관련 훅 호출 + const [isDelModalOpen, setIsDelModalOpen] = useState(false); + const [isEmptyModalOpen, setIsEmptyModalOpen] = useState(false); + const [categoryList, setCategoryList] = useState([]); + const navigate = useNavigate(); // 페이지 이동을 위한 훅 + + // * 사용자 대시보드 해시태그 불러오기 & 대시보드 수정이라면 대시보드 상세 데이터 불러오기 + const fetchData = async () => { + const list = await getCategories(); + setCategoryList(list ?? []); // getCategories에서 null이 반한되었을 때는 빈 배열로 설정 + + if (dashboardId) { + const data = await getPersonalDashboard(dashboardId); + setFormData({ + title: data?.title ?? '', // 제목 + description: data?.description ?? '', // 설명 + isPublic: data?.isPublic ?? false, // 공개 여부 + category: data?.category ?? '', // 카테고리 + }); + } + }; + + useEffect(() => { + fetchData(); + }, [dashboardId]); + + // input 데이터 설정 함수 (제목, 설명, 카테고리 - 직접 입력) + const handleChange = ( + event: React.ChangeEvent + ) => { + const { name, value } = event.target; + + setFormData(prevState => ({ ...prevState, [name]: value })); + }; + + // 공개범위 설정 함수 (전체 공개 / 나만 보기) + const handleScopeToggle = () => { + setFormData(prevState => ({ + ...prevState, + isPublic: !prevState.isPublic, + })); + }; + + // 제출시 빈 칸이 있나 확인하는 함수 (있다면 true) + const validateFormData = (formData: DashboardItem): boolean => { + return Object.values(formData).some(value => value === ''); + }; + + // 대시보드 생성(제출) 함수 + const submitDashboard = async () => { + // 빈 작성란이 있으면 모달창 띄우기. 모두 작성되었으면 최종 제출 + if (validateFormData(formData)) { + setIsEmptyModalOpen(true); + const handleModalClose = () => setIsEmptyModalOpen(false); + openModal('normal', handleModalClose); // yes, no 모두 모달창 끄도록 호출 + } else { + try { + const responseDashboardId = dashboardId + ? await patchDashBoard(dashboardId, formData) // 기존 대시보드 수정 + : await createDashBoard(formData); // 새 대시보드 생성 + navigate(`/${responseDashboardId}`); // 해당 대시보드 페이지로 이동 + } catch (error) { + console.error('개인 대시보드 생성 및 수정시 오류 발생!', error); + } + } + }; + + // * 개인 대시보드 삭제 api + const deleteDashboard = async () => { + if (dashboardId) { + await deletePersonalDashboard(dashboardId); + navigate('/'); + } + }; + + // * 개인 대시보드 삭제 모달창 + const submitDelDashboard = () => { + setIsDelModalOpen(true); + const handleModalClose = () => setIsDelModalOpen(false); + openModal('yes', deleteDashboard, handleModalClose); // yes 버튼이 눌릴 때만 대시보드 삭제 api 요청 + }; + + return { + formData, + categoryList, + isModalOpen, + handleChange, + handleScopeToggle, + submitDashboard, + handleYesClick, + handleNoClick, + submitDelDashboard, + isDelModalOpen, + isEmptyModalOpen, + }; +}; + +export default usePersonalDashBoard; + +/* + * 개인 대시보드 조회 커스텀 훅 + */ + +const usePersonalDashBoardSearch = () => { + const [dashboard, setDashboard] = useState(); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await searchPersonalDashBoard(); // 서버에서 데이터를 받아옵니다. + setDashboard(data); // 받아온 데이터를 상태에 저장합니다. + } catch (error) { + console.log(error); + } + }; + + fetchData(); + }, []); + + return { dashboard }; +}; + +export { usePersonalDashBoardSearch }; diff --git a/src/hooks/useSidePage.ts b/src/hooks/useSidePage.ts new file mode 100644 index 0000000..d349e25 --- /dev/null +++ b/src/hooks/useSidePage.ts @@ -0,0 +1,147 @@ +import { useState, useEffect } from 'react'; +import { useCreateBlockNote } from '@blocknote/react'; +import { BlockNoteEditor } from '@blocknote/core'; +import { getPersonalBlock, patchPersonalBlock } from '../api/PersonalBlockApi'; +import { useDebounce } from './useDebounce'; +import { BlockListResDto } from '../types/PersonalBlock'; + +// 훅의 반환값 타입 정의 +export interface SidePageState { + data: BlockListResDto; + handleTitleChange: (event: React.ChangeEvent) => void; + handleDateChange: (date: Date | null, type: 'start' | 'end') => void; + onChange: () => void; + editor: BlockNoteEditor | null; + SubmitData: () => void; + parseDate: (dateString?: string | null) => Date | null; +} + +export const useSidePage = (blockId: string | undefined, progress: string): SidePageState => { + const [data, setData] = useState({}); + + // 블록 에디터 초기화 + const editor = useCreateBlockNote(); + + useEffect(() => { + const fetchDataAndInitializeEditor = async () => { + if (blockId) { + try { + // 데이터 가져오기 + const fetchedData = await getPersonalBlock(blockId); + if (fetchedData) { + setData(fetchedData); + } + + // 에디터 초기화 + if (editor && fetchedData?.contents) { + const blocks = await editor.tryParseMarkdownToBlocks(fetchedData.contents); + editor.replaceBlocks(editor.document, blocks); + } + } catch (error) { + console.error('Error fetching data or initializing editor:', error); + } + } + }; + + fetchDataAndInitializeEditor(); + }, [blockId, editor]); // blockId와 editor가 변경될 때만 실행 + + // 제목 변경 함수 + const handleTitleChange = (event: React.ChangeEvent) => { + setData(prevData => ({ + ...prevData, + title: event.target.value, + })); + }; + + // 날짜 포맷 함수 + const formatDate = (date: Date | null): string => { + if (!date) return ''; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${year}.${month}.${day} ${hours}:${minutes}`; + }; + + // 날짜 변환 함수 + // string을 Date로 변환하는 함수 + const parseDate = (dateString?: string | null): Date | null => { + return dateString ? new Date(dateString) : null; + }; + + // DatePicker의 날짜 선택 핸들러 + const handleDateChange = (date: Date | null, type: 'start' | 'end') => { + setData(prevData => ({ + ...prevData, + [type === 'start' ? 'startDate' : 'deadLine']: date ? date.toISOString() : null, + })); + }; + + // startDate가 deadLine보다 이후로 설정되면 deadLine을 startDate로 변경 + useEffect(() => { + if (data.startDate && data.deadLine) { + const start = new Date(data.startDate); + const end = new Date(data.deadLine); + if (start > end) { + setData(prevData => ({ + ...prevData, + deadLine: start.toISOString(), + })); + } + } + }, [data.startDate]); + + // 본문 작성 함수 + const onChange = async () => { + if (editor) { + const markdownContent = await editor.blocksToMarkdownLossy(editor.document); + setData(prevData => ({ + ...prevData, + contents: markdownContent, + })); + } + }; + + // debounce : 300ms 입력이 감지되지 않으면 자동 저장 + const debouncedData = useDebounce(data, 300); + + useEffect(() => { + // console.log('debouce!'); + SubmitData(); + }, [debouncedData]); + + // patch api 요청 + const SubmitData = () => { + // 날짜를 string | null | undefined에서 Date | null로 변환 + const startDate = parseDate(data.startDate); + const endDate = parseDate(data.deadLine); + + // 날짜를 포맷하여 문자열로 변환 + const formattedStartDate = formatDate(startDate); + const formattedEndDate = formatDate(endDate); + + // 포맷된 날짜를 포함하여 요청할 데이터 객체 생성 + const patchData = { + ...data, + startDate: formattedStartDate, + deadLine: formattedEndDate, + }; + + // patch 요청 수행 + patchPersonalBlock(blockId, patchData); + }; + + return { + data, + handleTitleChange, + onChange, + editor, + SubmitData, + handleDateChange, + parseDate, + }; +}; diff --git a/src/hooks/useTeamDashBoard.ts b/src/hooks/useTeamDashBoard.ts new file mode 100644 index 0000000..de3f470 --- /dev/null +++ b/src/hooks/useTeamDashBoard.ts @@ -0,0 +1,28 @@ +/* + * 팀 대시보드 조회 커스텀 훅 + */ + +import { useEffect, useState } from 'react'; +import { searchTeamDashBoard } from '../api/BoardApi'; +import { TeamDashboardResponse } from '../types/TeamDashBoard'; + +const useTeamDashBoard = () => { + const [teamDashboard, setTeamDashboard] = useState(); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await searchTeamDashBoard(); // 서버에서 데이터를 받아옵니다. + setTeamDashboard(data); // 받아온 데이터를 상태에 저장합니다. + } catch (error) { + console.log(error); + } + }; + + fetchData(); + }, []); + + return { teamDashboard }; +}; + +export default useTeamDashBoard; diff --git a/src/hooks/useTeamDocument.ts b/src/hooks/useTeamDocument.ts new file mode 100644 index 0000000..0a0cfc5 --- /dev/null +++ b/src/hooks/useTeamDocument.ts @@ -0,0 +1,107 @@ +import { useState, useEffect } from 'react'; +import { useCreateBlockNote } from '@blocknote/react'; +import { BlockNoteEditor } from '@blocknote/core'; +import { getPersonalBlock, patchPersonalBlock } from '../api/PersonalBlockApi'; +import { useDebounce } from './useDebounce'; +import { BlockListResDto } from '../types/PersonalBlock'; +import { + getTeamDocumentCategories, + getTeamDocumentDetail, + patchTeamDocument, +} from '../api/TeamDocumentApi'; + +export interface SidePageState { + data: TeamDocument; + categories: string[]; + handleInputChange: (event: React.ChangeEvent) => void; + onChange: () => void; + editor: BlockNoteEditor | null; + SubmitData: () => void; +} + +export const useTeamDocument = ( + teamDashboardId: string, + teamDocumentId: string, + progress: string +): SidePageState => { + const [data, setData] = useState({}); + const [categories, setCategories] = useState([]); + + // 블록 에디터 초기화 + const editor = useCreateBlockNote(); + + useEffect(() => { + const fetchDataAndInitializeEditor = async () => { + if (teamDocumentId) { + try { + // 데이터 가져오기 + const fetchedData = await getTeamDocumentDetail(teamDocumentId); + console.log(fetchedData); + if (fetchedData) { + setData(fetchedData); + } + + // 카테고리 가져오기 + const fetchedCategories = await getTeamDocumentCategories(teamDashboardId); + console.log(fetchedCategories); + if (fetchedCategories) { + setCategories(fetchedCategories); + } + + // 에디터 초기화 + if (editor && fetchedData?.content) { + const blocks = await editor.tryParseMarkdownToBlocks(fetchedData.content); + editor.replaceBlocks(editor.document, blocks); + } + } catch (error) { + console.error('Error fetching data or initializing editor:', error); + } + } + }; + + fetchDataAndInitializeEditor(); + }, [teamDocumentId, editor]); // blockId와 editor가 변경될 때만 실행 + + // 제목, 카테고리 변경 함수 + const handleInputChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setData(prevData => ({ + ...prevData, + [name]: value, + })); + }; + + // 본문 작성 함수 + const onChange = async () => { + if (editor) { + const markdownContent = await editor.blocksToMarkdownLossy(editor.document); + setData(prevData => ({ + ...prevData, + content: markdownContent, + })); + } + }; + + // debounce : 300ms 입력이 감지되지 않으면 자동 저장 + const debouncedData = useDebounce(data, 300); + + useEffect(() => { + console.log('debouce!'); + SubmitData(); + }, [debouncedData]); + + // patch api 요청 + const SubmitData = () => { + // patch 요청 수행 + patchTeamDocument(data); + }; + + return { + data, + categories, + handleInputChange, + onChange, + editor, + SubmitData, + }; +}; diff --git a/src/img/Kkeujeok_logo.png b/src/img/Kkeujeok_logo.png new file mode 100644 index 0000000..6f98a79 Binary files /dev/null and b/src/img/Kkeujeok_logo.png differ diff --git a/src/img/Kkeujeok_logo2.png b/src/img/Kkeujeok_logo2.png new file mode 100644 index 0000000..af922f2 Binary files /dev/null and b/src/img/Kkeujeok_logo2.png differ diff --git a/src/img/addbutton.png b/src/img/addbutton.png new file mode 100644 index 0000000..fecd88a Binary files /dev/null and b/src/img/addbutton.png differ diff --git a/src/img/background_login.png b/src/img/background_login.png new file mode 100644 index 0000000..1dc107e Binary files /dev/null and b/src/img/background_login.png differ diff --git a/src/img/bell.png b/src/img/bell.png new file mode 100644 index 0000000..3d0718c Binary files /dev/null and b/src/img/bell.png differ diff --git a/src/img/closebutton.png b/src/img/closebutton.png new file mode 100644 index 0000000..17f7045 Binary files /dev/null and b/src/img/closebutton.png differ diff --git a/src/img/delete.png b/src/img/delete.png new file mode 100644 index 0000000..941b885 Binary files /dev/null and b/src/img/delete.png differ diff --git a/src/img/delete2.png b/src/img/delete2.png new file mode 100644 index 0000000..4229184 Binary files /dev/null and b/src/img/delete2.png differ diff --git a/src/img/edit.png b/src/img/edit.png new file mode 100644 index 0000000..019aefb Binary files /dev/null and b/src/img/edit.png differ diff --git a/src/img/error.png b/src/img/error.png new file mode 100644 index 0000000..a776d50 Binary files /dev/null and b/src/img/error.png differ diff --git a/src/img/fileimg.png b/src/img/fileimg.png new file mode 100644 index 0000000..344fe1c Binary files /dev/null and b/src/img/fileimg.png differ diff --git a/src/img/folderimg.png b/src/img/folderimg.png new file mode 100644 index 0000000..be3d8db Binary files /dev/null and b/src/img/folderimg.png differ diff --git a/src/img/googleLogin.png b/src/img/googleLogin.png new file mode 100644 index 0000000..0b89be8 Binary files /dev/null and b/src/img/googleLogin.png differ diff --git a/src/img/googleicon.png b/src/img/googleicon.png new file mode 100644 index 0000000..5d06023 Binary files /dev/null and b/src/img/googleicon.png differ diff --git a/src/img/kakaoLogin.png b/src/img/kakaoLogin.png new file mode 100644 index 0000000..d67c934 Binary files /dev/null and b/src/img/kakaoLogin.png differ diff --git a/src/img/kakaologo.png b/src/img/kakaologo.png new file mode 100644 index 0000000..659f412 Binary files /dev/null and b/src/img/kakaologo.png differ diff --git a/src/img/kakaoprofileimage.png b/src/img/kakaoprofileimage.png new file mode 100644 index 0000000..5326049 Binary files /dev/null and b/src/img/kakaoprofileimage.png differ diff --git a/src/img/kkeujeok_middle.png b/src/img/kkeujeok_middle.png new file mode 100644 index 0000000..7269b53 Binary files /dev/null and b/src/img/kkeujeok_middle.png differ diff --git a/src/img/kkeujeok_out.png b/src/img/kkeujeok_out.png new file mode 100644 index 0000000..3cb45f7 Binary files /dev/null and b/src/img/kkeujeok_out.png differ diff --git a/src/img/leftarrow.png b/src/img/leftarrow.png new file mode 100644 index 0000000..c70c420 Binary files /dev/null and b/src/img/leftarrow.png differ diff --git a/src/img/main.png b/src/img/main.png new file mode 100644 index 0000000..e5a39d2 Binary files /dev/null and b/src/img/main.png differ diff --git a/src/img/main2.png b/src/img/main2.png new file mode 100644 index 0000000..5b3b4e9 Binary files /dev/null and b/src/img/main2.png differ diff --git a/src/img/main3.png b/src/img/main3.png new file mode 100644 index 0000000..51d5a26 Binary files /dev/null and b/src/img/main3.png differ diff --git a/src/img/personalDashboardIcon.png b/src/img/personalDashboardIcon.png new file mode 100644 index 0000000..eb72596 Binary files /dev/null and b/src/img/personalDashboardIcon.png differ diff --git a/src/img/rightarrow.png b/src/img/rightarrow.png new file mode 100644 index 0000000..f5afd23 Binary files /dev/null and b/src/img/rightarrow.png differ diff --git a/src/img/setting.png b/src/img/setting.png new file mode 100644 index 0000000..22aef55 Binary files /dev/null and b/src/img/setting.png differ diff --git a/src/img/teamDashboardIcon.png b/src/img/teamDashboardIcon.png new file mode 100644 index 0000000..4fb88da Binary files /dev/null and b/src/img/teamDashboardIcon.png differ diff --git a/src/img/userDefault.png b/src/img/userDefault.png new file mode 100644 index 0000000..f66790f Binary files /dev/null and b/src/img/userDefault.png differ diff --git a/src/index.tsx b/src/index.tsx index 032464f..00def68 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,14 +3,14 @@ import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +import GlobalStyle from './styles/GlobalStyle'; -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - + <> + - + ); // If you want to start measuring performance in your app, pass a function diff --git a/src/pages/CreateBoardPage.tsx b/src/pages/CreateBoardPage.tsx new file mode 100644 index 0000000..6856b76 --- /dev/null +++ b/src/pages/CreateBoardPage.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import Flex from '../components/Flex'; + +import personalDashboardIcon from '../img/personalDashboardIcon.png'; +import teamDashboardIcon from '../img/teamDashboardIcon.png'; + +import { + CreateDashBoardLayout, + CreateDashBoardContainer, + BoardTitle, + CreateBoardWrapper, + Explanation, + LinksContainer, + PersonalIconImgWrapper, + SubTitle, + TeamIconImgWrapper, + Title, +} from '../styles/CreateBoardPageStyled'; + +const CreateBoard = () => { + return ( + + + + 끄적끄적을 어떻게 사용하고 싶으세요? + 생성할 대시보드 종류를 선택해주세요. + + + + + + + + 설정 이미지 + + + 정신 없는 하루도,
+ 복잡한 작업도 끄적끄적과 함께 해요. +
+ 개인 대시보드 +
+
+ + + + + + + 설정 이미지 + + + 협업도 문제 없어요.
+ 팀원을 등록하고 프로젝트를 관리해요. +
+ 팀 대시보드 +
+
+ +
+
+
+
+ ); +}; + +export default CreateBoard; diff --git a/src/pages/CreatePersonalBoardPage.tsx b/src/pages/CreatePersonalBoardPage.tsx new file mode 100644 index 0000000..8156ba1 --- /dev/null +++ b/src/pages/CreatePersonalBoardPage.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import Navbar from '../components/Navbar'; +import Flex from '../components/Flex'; +import { FaLock } from 'react-icons/fa'; +import { FaEarthAsia } from 'react-icons/fa6'; + +import CustomModal from '../components/CustomModal'; +import usePersonalDashBoard from '../hooks/usePersonalDashBoard'; + +import { + CreateDashBoardLayout, + CreateDashBoardContainer, + Title, + SubTitle, + CreateDashBoardModal, + SubmitBtn, + CreateForm, + Label, + Input, + Select, + RowWrapper, + Scope, + Textarea, + DelBtn, +} from '../styles/CreateBoardPageStyled'; +import { useLocation } from 'react-router-dom'; + +const CreatePersonalBoard = () => { + const location = useLocation(); + let dashboardId = location.pathname.split('/').pop() || null; + if (dashboardId === 'createPersonalBoard') dashboardId = null; // dashboard 첫 생성시 dashboard id 값을 null로 만들어 줌 + const { + formData, + categoryList, + isModalOpen, + handleChange, + handleScopeToggle, + submitDashboard, + handleYesClick, + handleNoClick, + submitDelDashboard, + isDelModalOpen, + isEmptyModalOpen, + } = usePersonalDashBoard(dashboardId); // 개인 대시보드 생성 커스텀 훅 사용 + + return ( + + + + + 개인 대시보드 {dashboardId ? '수정' : '생성'} + 제목과 설명, 카테고리를 설정하고 공개 여부를 선택하세요. + + + + + + + + + +