├── .eslintrc.js
├── .github
├── CODEOWNERS
└── pull_request_template.md
├── .gitignore
├── .husky
└── prepare-commit-msg
├── .prettierignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── _redirects
├── favicon.ico
├── favicon1.png
├── index.html
└── robots.txt
├── src
├── App.tsx
├── apis
│ ├── auth.ts
│ ├── instance.ts
│ ├── myRecord.ts
│ ├── record.ts
│ ├── reply.ts
│ ├── share.ts
│ └── user.ts
├── assets
│ ├── 404.svg
│ ├── 6x12RightArrow.svg
│ ├── Expand_right.svg
│ ├── ImageContainer
│ │ ├── left_Arrow.svg
│ │ └── right_Arrow.svg
│ ├── back.svg
│ ├── camera.svg
│ ├── check.svg
│ ├── chip_icon
│ │ ├── cake.svg
│ │ ├── celebrate.svg
│ │ ├── consolate.svg
│ │ ├── depress.svg
│ │ ├── happy.svg
│ │ ├── index.ts
│ │ ├── love.svg
│ │ ├── mySide.svg
│ │ └── sympathy.svg
│ ├── close_icon.svg
│ ├── collect_page_icon
│ │ ├── collapse.svg
│ │ ├── reset.svg
│ │ ├── reset_disabled.svg
│ │ └── scrollTop.svg
│ ├── constant
│ │ ├── RecordColors.ts
│ │ ├── RecordIcons.ts
│ │ ├── collect.ts
│ │ ├── constant.ts
│ │ ├── others.ts
│ │ └── ranking.ts
│ ├── deleteIcon.svg
│ ├── detail_page_icon
│ │ ├── Close.svg
│ │ ├── arrow_down.svg
│ │ └── arrow_up.svg
│ ├── firecracker.svg
│ ├── front.svg
│ ├── front_white.svg
│ ├── google.svg
│ ├── heart_large.svg
│ ├── home_img.svg
│ ├── icon_closed.svg
│ ├── kakao.svg
│ ├── moon_big.svg
│ ├── moon_large.svg
│ ├── more.svg
│ ├── myRecordIcon
│ │ ├── arrow_down.svg
│ │ ├── arrow_up.svg
│ │ ├── calendar.svg
│ │ ├── close.svg
│ │ ├── comment_plus.svg
│ │ └── search.svg
│ ├── nav_icons
│ │ ├── collect_icon.svg
│ │ ├── home_icon.svg
│ │ ├── myrecord_icon.svg
│ │ ├── record_icon.svg
│ │ └── setting_icon.svg
│ ├── pin.svg
│ ├── plus.svg
│ ├── present_box.svg
│ ├── ranking_btn_arrow.svg
│ ├── ranking_down_arrow.svg
│ ├── record_icons
│ │ ├── crown.svg
│ │ ├── gift.svg
│ │ ├── heart.svg
│ │ ├── index.ts
│ │ ├── like.svg
│ │ ├── lock.svg
│ │ ├── medal.svg
│ │ ├── moon.svg
│ │ ├── music.svg
│ │ ├── rocket.svg
│ │ ├── speechbubble.svg
│ │ ├── trashcan.svg
│ │ ├── umbrella.svg
│ │ └── wine.svg
│ ├── right_purple_arrow.svg
│ ├── settings_icon
│ │ ├── check_box.svg
│ │ └── reply_check.svg
│ ├── sharing_img.svg
│ ├── spinner.svg
│ ├── teamIntroductionImage.svg
│ └── umbrella_big.svg
├── components
│ ├── Alert.tsx
│ ├── BackButton.tsx
│ ├── Button.tsx
│ ├── Category.tsx
│ ├── Chip.tsx
│ ├── Input.tsx
│ ├── Layout.tsx
│ ├── Loading.tsx
│ ├── Modal.tsx
│ ├── MoreButton.tsx
│ ├── Navbar.tsx
│ ├── NavbarItem.tsx
│ ├── ParrentCategoryTab.tsx
│ ├── RankingItem.tsx
│ ├── RankingItemNoData.tsx
│ ├── RecordCard.tsx
│ ├── ScrollTop.tsx
│ ├── SmallToast.tsx
│ ├── Spinner.tsx
│ └── Toast.tsx
├── hooks
│ ├── useCheckMobile.ts
│ ├── useClickOutside.ts
│ ├── useDebounce.ts
│ ├── useImageSwipe.ts
│ ├── useIntersectionObserver.ts
│ ├── useSwipe.ts
│ ├── useThrottle.ts
│ └── useTimeoutFunc.ts
├── index.css
├── index.tsx
├── pages
│ ├── AddRecord
│ │ ├── AddRecord.tsx
│ │ ├── AddRecordCategory.tsx
│ │ ├── AddRecordColor.tsx
│ │ ├── AddRecordFile.tsx
│ │ ├── AddRecordIcon.tsx
│ │ ├── AddRecordInput.tsx
│ │ ├── AddRecordTextArea.tsx
│ │ └── AddRecordTitle.tsx
│ ├── Collect
│ │ ├── Collect.tsx
│ │ ├── CollectRanking.tsx
│ │ ├── PeriodModal.tsx
│ │ ├── RecentRecord.tsx
│ │ └── Timer.tsx
│ ├── DetailRecord
│ │ ├── DetailRecord.tsx
│ │ ├── EditModal.tsx
│ │ ├── ImageContainer.tsx
│ │ ├── InputAddImage.tsx
│ │ ├── InputSnackBar.tsx
│ │ ├── InputTextarea.tsx
│ │ ├── NestedReplyItem.tsx
│ │ ├── NestedReplyList.tsx
│ │ ├── ReplyInput.tsx
│ │ ├── ReplyItem.tsx
│ │ ├── ReplyList.tsx
│ │ ├── ShareModal.tsx
│ │ ├── getChipIconName.ts
│ │ └── getCreatedDate.ts
│ ├── Login
│ │ ├── GoogleButton.tsx
│ │ ├── KakaoButton.tsx
│ │ ├── Login.tsx
│ │ └── [type].tsx
│ ├── Main
│ │ ├── Main.tsx
│ │ ├── MixRecord.tsx
│ │ ├── Ranking.tsx
│ │ ├── RankingList.tsx
│ │ ├── Together.tsx
│ │ └── TogetherSlider.tsx
│ ├── MyRecord
│ │ ├── Calendar
│ │ │ ├── Calendar.tsx
│ │ │ ├── CalendarMonthYear.tsx
│ │ │ ├── CalendarRecord.tsx
│ │ │ ├── DateBox.tsx
│ │ │ └── getCalendarDetail.ts
│ │ ├── Common
│ │ │ ├── MemoryRecordCard.tsx
│ │ │ ├── MyRecordCard.tsx
│ │ │ └── SearchInput.tsx
│ │ ├── MemoryRecord.tsx
│ │ ├── MyRecord.tsx
│ │ ├── Search
│ │ │ └── SearchRecord.tsx
│ │ └── TodayRecord.tsx
│ ├── NotFound
│ │ └── NotFound.tsx
│ ├── Setting
│ │ ├── FeedbackMail
│ │ │ └── FeedbackMail.tsx
│ │ ├── ManageComment
│ │ │ ├── CommentSection.tsx
│ │ │ └── ManageComment.tsx
│ │ ├── ModifyInfo
│ │ │ └── ModifyInfo.tsx
│ │ ├── Setting.tsx
│ │ ├── SettingSection.tsx
│ │ ├── TeamIntroduction
│ │ │ └── TeamIntroduction.tsx
│ │ └── Withdraw
│ │ │ ├── CheckedNicknameBeforeWithDraw.tsx
│ │ │ ├── CompletedWithdraw.tsx
│ │ │ └── Withdraw.tsx
│ └── SignUp
│ │ ├── SignUp.tsx
│ │ └── WithdrawSignUp.tsx
├── react-app-env.d.ts
├── react-query
│ ├── hooks
│ │ ├── useAuth.ts
│ │ ├── useGetCategory.ts
│ │ ├── useGetReply.ts
│ │ ├── useMemoryRecord.ts
│ │ ├── useMyRecordByDate.ts
│ │ ├── useMyRecordByKeyword.ts
│ │ ├── useMyRecordByMonthYear.ts
│ │ ├── usePreviousUrlWithStorage.ts
│ │ ├── useRecentRecord.ts
│ │ └── useUser.ts
│ └── queryKeys.ts
├── routes
│ ├── protectedRoute.tsx
│ └── router.tsx
├── store
│ ├── atom.ts
│ ├── collectPageAtom.ts
│ ├── detailPageAtom.ts
│ ├── mainPageAtom.ts
│ └── myRecordAtom.ts
├── types
│ ├── auth.ts
│ ├── category.ts
│ ├── myRecord.ts
│ ├── recordData.ts
│ ├── replyData.ts
│ ├── request.ts
│ └── response.ts
└── utils
│ ├── fileSize.ts
│ ├── getCurrentTime.ts
│ ├── getFormattedDate.ts
│ ├── getIsValidateNickname.ts
│ ├── getTimeGap.ts
│ ├── localStorage.ts
│ └── sessionStorage.ts
├── tailwind.config.js
├── tsconfig.extend.json
├── tsconfig.json
└── webpack.config.js
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | node: true,
4 | browser: true,
5 | es2021: true,
6 | },
7 | extends: [
8 | 'prettier',
9 | 'prettier/prettier',
10 | 'plugin:prettier/recommended',
11 | 'eslint:recommended',
12 | 'plugin:react/recommended',
13 | 'plugin:@typescript-eslint/recommended',
14 | 'plugin:tailwindcss/recommended',
15 | ],
16 | parser: '@typescript-eslint/parser',
17 | parserOptions: {
18 | ecmaFeatures: {
19 | jsx: true,
20 | },
21 | ecmaVersion: 'latest',
22 | },
23 | plugins: ['react', '@typescript-eslint', 'tailwindcss'],
24 | rules: {
25 | indent: 'off',
26 | '@typescript-eslint/no-var-requires': 0,
27 | 'react/self-closing-comp': 'warn', // 셀프 클로징 태그 가능하면 적용
28 | 'no-console': 'warn',
29 | },
30 | }
31 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @Seongtaek-H @endmoseung @sukyeongh
2 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## 작업 내용
2 |
3 | ## 참고 이미지(선택)
4 |
5 | ## 어떤 점을 리뷰 받고 싶으신가요?
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.husky/prepare-commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx jira-prepare-commit-msg $1
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "semi": false,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "eslint.alwaysShowStatus": true,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll.eslint": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### 🎁 서비스 소개
2 |
3 | 
4 |
5 |
6 | 
7 |
8 |
9 |
10 | ### 💡레코딧의 KEY Point
11 |
12 | ```
13 | 1️⃣ 내 특별한 일상을 모두와 나눌 수 있어요.
14 | ```
15 |
16 | ```
17 | 2️⃣ 나의 모든 일상들을 나만의 공간에 기록하고 오래 보관하고 싶어요.
18 | ```
19 |
20 | ```
21 | 3️⃣ 익명으로 모르는 사람들에게도 축하 또는 위로를 받아요.
22 | ```
23 |
24 |
25 |
26 | ### ✨누가 필요해요?
27 |
28 | 
29 |
30 |
31 |
32 | ### ❓레코딧은요!
33 |
34 | 
35 |
36 |
37 |
38 | 
39 |
40 |
41 |
42 | 
43 |
44 |
45 |
46 | 
47 |
48 |
49 |
50 | ### 프론트엔드 팀원
51 |
52 |
76 |
77 | ### 백엔드
78 |
79 | [Go Repository](https://github.com/ItRecode/recordit-server)
80 |
81 | ### 기술 스택
82 |
83 |
84 |
85 |
86 |
87 |
88 | ### 🏠 텐져스 문화
89 |
90 | #### 1. 호칭은 닉네임으로
91 |
92 | - 모든 호칭은 닉네임으로 하며 ‘님’자는 생략해요.
93 |
94 | #### 2. 이해하기 쉬운 단어 선택
95 |
96 | - 비개발직군도 이해할 수 있게 쉬운 단어로 소통해요.
97 |
98 | #### 3. 정리하고 공유하는 습관
99 |
100 | - 담당자가 부재 시 다른 팀원들에게 쉬운 handoff를 위해 하루가 끝나기 전에 공유해요.
101 | - 모르는 게 있다면 팀원들에게 자유롭게 질문해요.
102 | - 습득한 지식이나 트러블 슈팅 내용을 기술 블로그에 공유해요.
103 |
104 | #### 4. 개발은 혼자 하지 않아요
105 |
106 | - PR은 꼭 1명 이상 approve를 받고 merge 해요.
107 | - 코드 리뷰를 할 때는 항상 상대방의 기분을 먼저 생각하면서 자신의 의견을 제시해요.
108 |
109 | #### 5. 나의 테스크가 아니라고 외면하지 않기
110 |
111 | - 다른 팀원의 테스크라도 팀원이 어려워한다면 먼저 적극적으로 소통하려 하고, 협력해서 문제를 해결해요.
112 |
113 | #### 6. 팀워크는 생명
114 |
115 | - 회식, 온라인 모각코, 마니또, 마피아 게임 등 다양한 활동을 하며 서로를 알아가요.
116 |
117 | #### 7. 애자일한 팀 프로젝트
118 |
119 | - JIRA를 활용해 스프린트의 이슈와 일정을 정리해요.
120 | - 매일 오전 9시에 데일리 스크럼을 진행해요.
121 |
122 | #### 8. QA 진행
123 |
124 | - 매주 금요일 QA를 진행하여 더욱 퀄리티 있는 서비스를 만들어요.
125 |
126 | #### 9. 스프린트의 마지막은 KPT 회고로
127 |
128 | - 매주 월요일 팀원들이 모두 모여 솔직하게 아쉬운 점, 좋았던 점을 얘기하며 팀을 발전해 나가요.
129 |
130 | #### 10. 개인 일정 공유하기
131 |
132 | - 스프린트 시작 전 개인 일정을 미리 공유해요.
133 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | const CracoAlias = require('craco-alias')
2 |
3 | module.exports = {
4 | plugins: [
5 | {
6 | plugin: CracoAlias,
7 | options: {
8 | source: 'tsconfig',
9 | baseUrl: './src',
10 | tsConfigPath: 'tsconfig.extend.json',
11 | },
12 | },
13 | ],
14 | }
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "recodeit-client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@craco/craco": "^7.0.0",
7 | "@emailjs/browser": "^3.10.0",
8 | "@tanstack/react-query": "^4.20.4",
9 | "@tanstack/react-query-devtools": "^4.20.4",
10 | "@testing-library/jest-dom": "^5.16.5",
11 | "@testing-library/react": "^13.4.0",
12 | "@testing-library/user-event": "^13.5.0",
13 | "@types/jest": "^27.5.2",
14 | "@types/node": "^16.18.10",
15 | "@types/react": "^18.0.26",
16 | "@types/react-dom": "^18.0.9",
17 | "axios": "^1.2.1",
18 | "history": "^5.3.0",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "react-router-dom": "^6.5.0",
22 | "react-scripts": "5.0.1",
23 | "react-slick": "^0.29.0",
24 | "recoil": "^0.7.6",
25 | "slick-carousel": "^1.8.1",
26 | "typescript": "^4.9.4",
27 | "web-vitals": "^2.1.4"
28 | },
29 | "scripts": {
30 | "start": "craco start",
31 | "build": "craco build",
32 | "test": "craco test",
33 | "eject": "react-scripts eject"
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | },
47 | "devDependencies": {
48 | "@types/react-slick": "^0.23.10",
49 | "@types/recoil": "^0.0.9",
50 | "@typescript-eslint/eslint-plugin": "^5.46.1",
51 | "@typescript-eslint/parser": "^5.46.1",
52 | "autoprefixer": "^10.4.13",
53 | "craco-alias": "^3.0.1",
54 | "eslint": "^8.30.0",
55 | "eslint-config-prettier": "^8.5.0",
56 | "eslint-plugin-prettier": "^4.2.1",
57 | "eslint-plugin-react": "^7.31.11",
58 | "eslint-plugin-tailwindcss": "^3.7.1",
59 | "husky": "^8.0.2",
60 | "jira-prepare-commit-msg": "^1.7.1",
61 | "postcss": "^8.4.20",
62 | "postcss-loader": "^7.0.2",
63 | "prettier": "^2.8.1",
64 | "prettier-plugin-tailwindcss": "^0.2.1",
65 | "tailwindcss": "^3.2.4"
66 | },
67 | "jira-prepare-commit-msg": {
68 | "messagePattern": "[$J] $M",
69 | "jiraTicketPattern": "([A-Z]+-\\d+)",
70 | "commentChar": "#",
71 | "isConventionalCommit": false,
72 | "allowEmptyCommitMessage": false,
73 | "gitRoot": ""
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ItRecode/recodeIt-client/434456a5f668211aa2348992eac0550a473be636/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ItRecode/recodeIt-client/434456a5f668211aa2348992eac0550a473be636/public/favicon1.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
17 |
21 |
25 | RecordIt
26 |
31 |
35 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
3 | import { RecoilRoot } from 'recoil'
4 |
5 | import React from 'react'
6 | import Layout from '@components/Layout'
7 | import { RouterProvider } from 'react-router-dom'
8 | import router from '@routes/router'
9 |
10 | const queryClient = new QueryClient()
11 |
12 | function App() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export default App
26 |
--------------------------------------------------------------------------------
/src/apis/auth.ts:
--------------------------------------------------------------------------------
1 | import { IAuth, ISignUp } from 'types/auth'
2 | import { baseInstance } from './instance'
3 |
4 | export const login = ({ type, token }: IAuth) => {
5 | return baseInstance.post(`/member/oauth/login/${type}`, {
6 | oauthToken: token,
7 | })
8 | }
9 |
10 | export const signUp = ({ type, tempId, nickname }: ISignUp) => {
11 | return baseInstance.post(`/member/oauth/register/${type}`, {
12 | nickname: nickname,
13 | registerSession: tempId,
14 | })
15 | }
16 |
17 | export const getIsDuplicatedNickname = (nickname: string) => {
18 | return baseInstance.get(`/member/nickname`, {
19 | params: { nickname },
20 | })
21 | }
22 |
23 | export const logout = () => {
24 | return baseInstance.post(`/member/logout`)
25 | }
26 |
27 | export const withdrawUser = () => {
28 | return baseInstance.delete(`/member/delete`)
29 | }
30 |
--------------------------------------------------------------------------------
/src/apis/instance.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | const { REACT_APP_DEV_API_END_POINT } = process.env
4 |
5 | const baseInstance = axios.create({
6 | baseURL: REACT_APP_DEV_API_END_POINT,
7 | withCredentials: true,
8 | timeout: 5000,
9 | })
10 |
11 | export { baseInstance }
12 |
--------------------------------------------------------------------------------
/src/apis/myRecord.ts:
--------------------------------------------------------------------------------
1 | import { AxiosResponse } from 'axios'
2 | import { baseInstance } from './instance'
3 | import {
4 | IMemoryRecordList,
5 | IMyRecord,
6 | IMyRecordByKeywordList,
7 | IRecordWithMonthYear,
8 | } from 'types/myRecord'
9 |
10 | const MEMORY_RECORD_SIZE = 7
11 | const MEMORY_COMMENT_SIZE = 5
12 | const MY_RECORD_KEYWORD_SIZE = 10
13 |
14 | export const getMemoryRecord = (
15 | pageParam: number,
16 | date: string
17 | ): Promise> => {
18 | return baseInstance.get(`/record/memory`, {
19 | params: {
20 | date,
21 | memoryRecordPage: pageParam,
22 | memoryRecordSize: MEMORY_RECORD_SIZE,
23 | sizeOfCommentPerRecord: MEMORY_COMMENT_SIZE,
24 | },
25 | })
26 | }
27 |
28 | export const getRecordOnToday = (): Promise> => {
29 | return baseInstance.get(`/record/today`)
30 | }
31 |
32 | export const getRecordByKeyword = (
33 | pageParam: number,
34 | keyword: string
35 | ): Promise> => {
36 | return baseInstance.get(`/record/search`, {
37 | params: {
38 | searchKeyword: keyword,
39 | page: pageParam,
40 | size: MY_RECORD_KEYWORD_SIZE,
41 | },
42 | })
43 | }
44 |
45 | export const getRecordByMonthYear = (
46 | yearMonth: string
47 | ): Promise> => {
48 | return baseInstance.get(`/record/days`, {
49 | params: {
50 | yearMonth,
51 | },
52 | })
53 | }
54 |
--------------------------------------------------------------------------------
/src/apis/record.ts:
--------------------------------------------------------------------------------
1 | import { parentCategoryID } from './../types/category'
2 | import { baseInstance } from './instance'
3 |
4 | export const getCategory = async (categoryId?: parentCategoryID) => {
5 | return await baseInstance.get('/record/category', {
6 | params: { parentRecordCategoryId: categoryId },
7 | })
8 | }
9 |
10 | export const enrollRecord = async (data: FormData) => {
11 | return baseInstance.post('/record', data, {
12 | headers: { 'Content-Type': 'multipart/form-data' },
13 | })
14 | }
15 |
16 | export const getRecord = async (recordId: string | undefined) => {
17 | if (recordId) {
18 | const res = await baseInstance.get(`/record/${recordId}`)
19 | return res.data
20 | }
21 | }
22 |
23 | export const deleteRecord = async (recordId: string | undefined) => {
24 | if (recordId) {
25 | const res = await baseInstance.delete(`/record/${recordId}`)
26 | return res.data
27 | }
28 | }
29 |
30 | export const modifyRecord = async (
31 | recordId: string | undefined,
32 | data: FormData
33 | ) => {
34 | return baseInstance.put(`/record/${recordId}`, data, {
35 | headers: { 'Content-Type': 'multipart/form-data' },
36 | })
37 | }
38 |
39 | export const getRandomRecordData = async (recordCategoryId: 1 | 2) => {
40 | return await baseInstance.get('/record/random', {
41 | params: { recordCategoryId, size: 5 },
42 | })
43 | }
44 |
45 | export const getMixRecordData = async () => {
46 | return await baseInstance.get('/record/mix')
47 | }
48 |
49 | export const getRecentRecordData = async (page: number, dateTime: string) => {
50 | const MAX_RECORD_NUMBER = 10
51 | return await baseInstance.get('/record/recent', {
52 | params: { page, size: MAX_RECORD_NUMBER, dateTime },
53 | })
54 | }
55 |
56 | export const getRanking = async (
57 | recordCategoryId: number,
58 | rankingPeriod = 'WEEK'
59 | ) => {
60 | return await baseInstance.get('/record/ranking', {
61 | params: {
62 | rankingPeriod,
63 | recordCategoryId,
64 | },
65 | })
66 | }
67 |
68 | export const getTotalRecordCount = async () => {
69 | return await baseInstance.get('/record/count')
70 | }
71 |
--------------------------------------------------------------------------------
/src/apis/reply.ts:
--------------------------------------------------------------------------------
1 | import { baseInstance } from './instance'
2 |
3 | export const createReply = async (data: FormData) => {
4 | return await baseInstance.post('/comment', data, {
5 | headers: { 'Content-Type': 'multipart/form-data' },
6 | })
7 | }
8 |
9 | export const getReply = async (
10 | recordId?: T,
11 | pageParam?: number,
12 | parentId?: number,
13 | size?: number
14 | ) => {
15 | return await baseInstance.get('/comment', {
16 | params: {
17 | page: pageParam ? pageParam : 0,
18 | parentId: parentId ? parentId : '',
19 | recordId: recordId,
20 | size: size ? size : 10,
21 | },
22 | })
23 | }
24 |
25 | export const deleteReply = async (
26 | commentId: number,
27 | recordId: string | undefined
28 | ) => {
29 | return await baseInstance.delete(`/comment/${commentId}?recordId=${recordId}`)
30 | }
31 |
32 | export const updateComment = async ({
33 | data,
34 | commentId,
35 | }: {
36 | data: FormData
37 | commentId: number
38 | }) => {
39 | return await baseInstance.put(`/comment/${commentId}`, data, {
40 | headers: { 'Content-Type': 'multipart/form-data' },
41 | })
42 | }
43 |
44 | export const getMyReply = async (pageParam?: number, size?: number) => {
45 | return await baseInstance.get('/comment/my', {
46 | params: {
47 | page: pageParam ? pageParam : 0,
48 | size: size ? size : 10,
49 | },
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/src/apis/share.ts:
--------------------------------------------------------------------------------
1 | interface IShareDataType {
2 | recordId: number
3 | title: string
4 | description: string
5 | imageUrl?: string
6 | }
7 | export const ShareKakao = ({
8 | recordId,
9 | title,
10 | description,
11 | imageUrl,
12 | }: IShareDataType) => {
13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
14 | const { Kakao }: any = window
15 | if (!Kakao.isInitialized()) {
16 | Kakao.init(process.env.REACT_APP_KAKAO_JAVASCRIPT_KEY)
17 | }
18 |
19 | Kakao.Share.sendDefault({
20 | objectType: 'feed',
21 | content: {
22 | title: title,
23 | description: description.replaceAll(/(
|
|
)/g, '\r\n'),
24 | imageUrl: imageUrl
25 | ? imageUrl
26 | : 'https://record-it.s3.ap-northeast-2.amazonaws.com/imagefile-dev/sharing+png',
27 |
28 | link: {
29 | webUrl: process.env.REACT_APP_WEB_URL,
30 | mobileWebUrl: process.env.REACT_APP_WEB_URL,
31 | },
32 | },
33 |
34 | buttons: [
35 | {
36 | title: '레코드 보러가기',
37 | link: {
38 | mobileWebUrl: `${process.env.REACT_APP_WEB_URL}/record/${recordId}`,
39 | webUrl: `${process.env.REACT_APP_WEB_URL}/record/${recordId}`,
40 | },
41 | },
42 | ],
43 | })
44 | }
45 |
46 | export const copyLink = async (recordId: number) => {
47 | try {
48 | await navigator.clipboard.writeText(
49 | `${window.location.origin}/record/${recordId}`
50 | )
51 | alert('링크를 복사했습니다.')
52 | } catch (error) {
53 | alert('링크 복사에 실패했어요.')
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/apis/user.ts:
--------------------------------------------------------------------------------
1 | import { baseInstance } from './instance'
2 |
3 | export const getUserInfo = () => {
4 | return baseInstance.get(`/member/auth`)
5 | }
6 |
7 | export const updateUserInfo = (nickname: string) => {
8 | return baseInstance.put(`/member`, { nickName: nickname })
9 | }
10 |
--------------------------------------------------------------------------------
/src/assets/6x12RightArrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/Expand_right.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/ImageContainer/left_Arrow.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/ImageContainer/right_Arrow.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/back.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/camera.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/check.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/chip_icon/index.ts:
--------------------------------------------------------------------------------
1 | import Celebrate from '@assets/chip_icon/celebrate.svg'
2 | import Happy from '@assets/chip_icon/happy.svg'
3 | import Cake from '@assets/chip_icon/cake.svg'
4 | import Love from '@assets/chip_icon/love.svg'
5 | import Consolate from '@assets/chip_icon/consolate.svg'
6 | import Sympathy from '@assets/chip_icon/sympathy.svg'
7 | import MySide from '@assets/chip_icon/mySide.svg'
8 | import Depress from '@assets/chip_icon/depress.svg'
9 |
10 | export { Celebrate, Happy, Cake, Love, Consolate, Sympathy, MySide, Depress }
11 |
--------------------------------------------------------------------------------
/src/assets/close_icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/collect_page_icon/collapse.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/collect_page_icon/reset.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/collect_page_icon/reset_disabled.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/collect_page_icon/scrollTop.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/assets/constant/RecordColors.ts:
--------------------------------------------------------------------------------
1 | export type colorSourceType = {
2 | src: string
3 | choosed: boolean
4 | id: number
5 | }
6 |
7 | export const ADD_RECORD_COLORS = [
8 | { src: 'bg-icon-purple', choosed: true, id: 0 },
9 | { src: 'bg-icon-yellow', choosed: false, id: 1 },
10 | { src: 'bg-icon-pink', choosed: false, id: 2 },
11 | { src: 'bg-icon-blue', choosed: false, id: 3 },
12 | { src: 'bg-icon-green', choosed: false, id: 4 },
13 | ]
14 |
--------------------------------------------------------------------------------
/src/assets/constant/RecordIcons.ts:
--------------------------------------------------------------------------------
1 | import heart from '@assets/record_icons/heart.svg'
2 | import gift from '@assets/record_icons/gift.svg'
3 | import music from '@assets/record_icons/music.svg'
4 | import rocket from '@assets/record_icons/rocket.svg'
5 | import like from '@assets/record_icons/like.svg'
6 | import crown from '@assets/record_icons/crown.svg'
7 | import medal from '@assets/record_icons/medal.svg'
8 | import moon from '@assets/record_icons/moon.svg'
9 | import speechbubble from '@assets/record_icons/speechbubble.svg'
10 | import wine from '@assets/record_icons/wine.svg'
11 | import umbrella from '@assets/record_icons/umbrella.svg'
12 | import trashcan from '@assets/record_icons/trashcan.svg'
13 | import lock from '@assets/record_icons/lock.svg'
14 |
15 | export const ADD_RECORD_ICONS = Object.freeze({
16 | celebration: [
17 | { src: gift, id: 0 },
18 | { src: heart, id: 1 },
19 | { src: music, id: 2 },
20 | { src: rocket, id: 3 },
21 | { src: like, id: 4 },
22 | { src: crown, id: 5 },
23 | { src: medal, id: 6 },
24 | ],
25 | consolation: [
26 | { src: moon, id: 0 },
27 | { src: speechbubble, id: 1 },
28 | { src: wine, id: 2 },
29 | { src: umbrella, id: 3 },
30 | { src: trashcan, id: 4 },
31 | { src: lock, id: 5 },
32 | ],
33 | })
34 |
--------------------------------------------------------------------------------
/src/assets/constant/collect.ts:
--------------------------------------------------------------------------------
1 | export const RESET_TIME = 180
2 |
--------------------------------------------------------------------------------
/src/assets/constant/constant.ts:
--------------------------------------------------------------------------------
1 | export const INPUT_DETAILS = Object.freeze({
2 | MAX_INPUT_TYPING: 12,
3 | MAX_TEXTAREA_TYPING: 200,
4 | MIN_TYPING: 0,
5 | })
6 |
7 | export const RECORD_TITLE_MAX_LENGTH = 12
8 |
9 | export const TEXT_DETAILS = Object.freeze({
10 | CELEBRATION: 'celebration',
11 | CONSOLATION: 'consolation',
12 | })
13 |
14 | export const CELEBRATION_ID = 1
15 | export const CONSOLATION_ID = 2
16 |
17 | export const INITIAL_RECORD_DATA = {
18 | recordId: 0,
19 | categoryId: 0,
20 | categoryName: '',
21 | title: '',
22 | content: '',
23 | writer: '',
24 | colorName: '',
25 | iconName: '',
26 | createdAt: '',
27 | imageUrls: [],
28 | }
29 |
30 | export const UNAUTHORIZED_CODE = 401
31 |
32 | export const RECORD_DETAIL_INITIAL_INPUT_HEIGHT = 89
33 | export const RECORD_DETAIL_INPUT_IMAGE_HEIGHT = 74
34 | export const RECORD_DETAIL_INPUT_HEIGHT_WITHOUT_TEXTAREA = 64
35 | export const RECORD_DETAIL_INPUT_TEXTAREAT_INITIAL_HEIGHT = 25
36 |
37 | export const INPUT_MODE = Object.freeze({
38 | REPLY: 'reply',
39 | NESTEDREPLY: 'nestedReply',
40 | })
41 |
42 | export const NICKNAME_MIN_LENGTH = 2
43 | export const NICKNAME_MAX_LENGTH = 8
44 |
--------------------------------------------------------------------------------
/src/assets/constant/others.ts:
--------------------------------------------------------------------------------
1 | export const PREVIOUS_URL = 'previousUrl'
2 |
--------------------------------------------------------------------------------
/src/assets/constant/ranking.ts:
--------------------------------------------------------------------------------
1 | export type keyOfRankingPeriod = 'TOTAL' | 'DAY' | 'WEEK' | 'MONTH'
2 |
3 | export type rankingPeriodType = {
4 | [key in keyOfRankingPeriod]: '하루' | '일주일' | '한 달' | '누적'
5 | }
6 | export const RANKINGPERIOD: rankingPeriodType = {
7 | DAY: '하루',
8 | WEEK: '일주일',
9 | MONTH: '한 달',
10 | TOTAL: '누적',
11 | }
12 |
--------------------------------------------------------------------------------
/src/assets/deleteIcon.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/assets/detail_page_icon/Close.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/detail_page_icon/arrow_down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/detail_page_icon/arrow_up.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/front.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/front_white.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/google.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/assets/icon_closed.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/assets/kakao.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/more.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/assets/myRecordIcon/arrow_down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/myRecordIcon/arrow_up.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/myRecordIcon/calendar.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/myRecordIcon/close.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/myRecordIcon/comment_plus.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/myRecordIcon/search.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/nav_icons/collect_icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/nav_icons/home_icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/nav_icons/myrecord_icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/nav_icons/record_icon.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/src/assets/nav_icons/setting_icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/pin.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/plus.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/assets/ranking_btn_arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/ranking_down_arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/record_icons/index.ts:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from 'react'
2 | import { ReactComponent as crown } from './crown.svg'
3 | import { ReactComponent as gift } from './gift.svg'
4 | import { ReactComponent as heart } from './heart.svg'
5 | import { ReactComponent as like } from './like.svg'
6 | import { ReactComponent as lock } from './lock.svg'
7 | import { ReactComponent as medal } from './medal.svg'
8 | import { ReactComponent as moon } from './moon.svg'
9 | import { ReactComponent as music } from './music.svg'
10 | import { ReactComponent as rocket } from './rocket.svg'
11 | import { ReactComponent as speechbubble } from './speechbubble.svg'
12 | import { ReactComponent as trashcan } from './trashcan.svg'
13 | import { ReactComponent as umbrella } from './umbrella.svg'
14 | import { ReactComponent as wine } from './wine.svg'
15 |
16 | type iconType = {
17 | [index: string]: FunctionComponent>
18 | }
19 |
20 | const icons: iconType = {
21 | crown,
22 | gift,
23 | heart,
24 | like,
25 | lock,
26 | medal,
27 | moon,
28 | music,
29 | rocket,
30 | speechbubble,
31 | trashcan,
32 | umbrella,
33 | wine,
34 | }
35 |
36 | export default icons
37 |
--------------------------------------------------------------------------------
/src/assets/right_purple_arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/settings_icon/check_box.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/assets/settings_icon/reply_check.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/assets/spinner.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/src/components/Alert.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react'
2 | import Modal from './Modal'
3 | interface IAlertProps {
4 | visible: boolean
5 | mainMessage: ReactNode
6 | subMessage?: ReactNode
7 | cancelMessage?: string
8 | confirmMessage: string
9 | onClose: () => void
10 | onCancel?: () => void
11 | onConfirm: () => void
12 | danger?: boolean
13 | }
14 |
15 | export default function Alert({
16 | visible,
17 | mainMessage,
18 | subMessage,
19 | cancelMessage,
20 | confirmMessage,
21 | onClose,
22 | onCancel,
23 | onConfirm,
24 | danger,
25 | }: IAlertProps) {
26 | const buttonClassName =
27 | 'h-full w-1/2 cursor-pointer bg-transparent py-4 text-base font-semibold'
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | {mainMessage}
35 |
36 | {subMessage && (
37 |
38 | {subMessage}
39 |
40 | )}
41 |
42 |
43 | {onCancel && (
44 |
51 | )}
52 |
53 |
62 |
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/BackButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ReactComponent as Back } from '@assets/back.svg'
3 | import { useNavigate } from 'react-router-dom'
4 |
5 | const BackButton = React.memo(function BackBtn({
6 | onClick,
7 | }: {
8 | onClick?: () => void
9 | }) {
10 | const navigate = useNavigate()
11 |
12 | const handleLocateBack = () => {
13 | if (navigate(-1) === undefined) {
14 | navigate('/')
15 | } else {
16 | navigate(-1)
17 |
18 | if (onClick) {
19 | return onClick()
20 | }
21 | }
22 | }
23 |
24 | return
25 | })
26 |
27 | export default BackButton
28 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Spinner from './Spinner'
3 |
4 | interface ButtonPropsType
5 | extends React.ButtonHTMLAttributes {
6 | property?: 'solid' | 'primary' | 'default' | 'danger'
7 | small?: boolean
8 | active?: boolean
9 | normal?: boolean
10 | disabled?: boolean
11 | loading?: boolean
12 | type?: 'button' | 'submit' | 'reset' | undefined
13 | children?: React.ReactElement | string
14 | }
15 |
16 | export default function Button({
17 | property = 'default',
18 | small = false,
19 | active = true,
20 | normal = false,
21 | disabled = false,
22 | loading = false,
23 | type = 'button',
24 | children,
25 | ...props
26 | }: ButtonPropsType) {
27 | const setClassNameByProperty = (property: string) => {
28 | switch (property) {
29 | case 'solid':
30 | return active
31 | ? 'bg-primary-2 text-grey-1 hover:bg-primary-1'
32 | : 'bg-inactive text-grey-1 '
33 | case 'primary':
34 | return active
35 | ? 'bg-primary-10 text-primary-2 hover:bg-primary-8 hover:text-primary-1'
36 | : 'bg-primary-10 text-primary-8'
37 | case 'default':
38 | return active
39 | ? 'border border-solid border-primary-3 bg-grey-1 text-primary-3 hover:border-primary-1 hover:text-primary-1'
40 | : 'border border-solid border-inactive text-inactive bg-grey-1'
41 | case 'danger':
42 | return active
43 | ? `border border-solid bg-grey-1 ${
44 | normal
45 | ? 'border-grey-6 text-grey-6 hover:border-grey-7'
46 | : 'border-danger text-danger hover:border-danger'
47 | }`
48 | : 'border border-solid border-inactive text-inactive'
49 | default:
50 | return ''
51 | }
52 | }
53 |
54 | return (
55 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/Category.tsx:
--------------------------------------------------------------------------------
1 | import { useGetCategory } from '@react-query/hooks/useGetCategory'
2 | import React, { Dispatch, SetStateAction, useEffect, useRef } from 'react'
3 | import { parentCategoryID } from 'types/category'
4 | import { getChipIconName } from '@pages/DetailRecord/getChipIconName'
5 | import Chip from './Chip'
6 | import useSwipe from '@hooks/useSwipe'
7 | import { CELEBRATION_ID, CONSOLATION_ID } from '@assets/constant/constant'
8 | import { useRecoilValue } from 'recoil'
9 | import { checkFromDetailPage } from '@store/detailPageAtom'
10 |
11 | export default function Category({
12 | slider,
13 | parentCategoryId,
14 | choosedCategoryId,
15 | setChoosedCategoryId,
16 | isModify = false,
17 | }: {
18 | slider: boolean
19 | parentCategoryId: parentCategoryID
20 | choosedCategoryId: number
21 | setChoosedCategoryId: Dispatch>
22 | isModify?: boolean
23 | }) {
24 | const { categoryData } = useGetCategory(parentCategoryId)
25 |
26 | const dragRef = useRef(
27 | null
28 | ) as React.MutableRefObject
29 | const { handleMouseDown, isDragging, setIsDragging } = useSwipe(dragRef)
30 |
31 | const isFromDetailPage = useRecoilValue(checkFromDetailPage)
32 |
33 | useEffect(() => {
34 | if (slider) {
35 | if (
36 | choosedCategoryId !== CELEBRATION_ID &&
37 | choosedCategoryId !== CONSOLATION_ID &&
38 | !isFromDetailPage
39 | ) {
40 | if (parentCategoryId === CELEBRATION_ID)
41 | setChoosedCategoryId(CELEBRATION_ID)
42 | if (parentCategoryId === CONSOLATION_ID)
43 | setChoosedCategoryId(CONSOLATION_ID)
44 | }
45 | dragRef.current.scrollLeft = 0
46 | }
47 | if (!slider) {
48 | if (parentCategoryId === CELEBRATION_ID) setChoosedCategoryId(3)
49 | if (parentCategoryId === CONSOLATION_ID) setChoosedCategoryId(7)
50 | }
51 | }, [parentCategoryId])
52 |
53 | const handleClickChip = (id?: number) => {
54 | if (slider && isDragging) {
55 | setIsDragging(false)
56 | return
57 | }
58 | if (id !== undefined) {
59 | setChoosedCategoryId(id)
60 | } else {
61 | setChoosedCategoryId(parentCategoryId)
62 | }
63 | }
64 |
65 | return (
66 | {
72 | handleMouseDown(e)
73 | }}
74 | >
75 | {slider && (
76 | handleClickChip()}
85 | />
86 | )}
87 | {categoryData &&
88 | categoryData.map((item) => (
89 | handleClickChip(item.id)}
96 | isModify={isModify}
97 | />
98 | ))}
99 |
100 | )
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/Chip.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 |
3 | interface chipProps extends React.ButtonHTMLAttributes {
4 | type?: 'button' | 'submit' | 'reset' | undefined
5 | active: boolean
6 | icon: string | null
7 | message: string
8 | pointer?: boolean
9 | property?: 'default' | 'small'
10 | isModify?: boolean
11 | }
12 |
13 | function Chip({
14 | active,
15 | icon = null,
16 | message,
17 | type,
18 | pointer = true,
19 | property = 'default',
20 | isModify,
21 | ...props
22 | }: chipProps) {
23 | const scrollRef = useRef(null)
24 |
25 | useEffect(() => {
26 | if (active) {
27 | scrollRef.current?.scrollIntoView({
28 | behavior: 'auto',
29 | block: 'nearest',
30 | inline: 'center',
31 | })
32 | }
33 | }, [])
34 |
35 | return (
36 |
61 | )
62 | }
63 |
64 | export default Chip
65 |
--------------------------------------------------------------------------------
/src/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ReactComponent as CloseIcon } from '@assets/icon_closed.svg'
3 |
4 | interface InputPropsType extends React.InputHTMLAttributes {
5 | property?: 'default' | 'success' | 'error'
6 | name: string
7 | label?: string
8 | placeholder?: string
9 | value?: number | string
10 | message?: string
11 | focus?: boolean
12 | autoFocus?: boolean
13 | maxLength?: number
14 | onRemove?: (isRemove: boolean) => void
15 | }
16 |
17 | export default function Input({
18 | property = 'default',
19 | name,
20 | label,
21 | placeholder,
22 | value,
23 | message,
24 | autoFocus = true,
25 | maxLength,
26 | onRemove,
27 | ...props
28 | }: InputPropsType) {
29 | const setClassNameByProperty = (property: string) => {
30 | if (property === 'error') return 'border-b-sub-1'
31 | if (property === 'success') return 'border-b-primary-1'
32 |
33 | return 'border-b-grey-4'
34 | }
35 |
36 | const handleRemove = () => {
37 | if (onRemove) {
38 | onRemove(true)
39 | }
40 | }
41 |
42 | return (
43 |
44 | {label &&
{label}
}
45 |
46 |
58 |
62 |
63 | {property !== 'default' && (
64 |
69 | {message}
70 |
71 | )}
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return (
5 |
6 |
7 | {children}
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Spinner from './Spinner'
3 |
4 | const Loading = React.memo(function LoadingComponent() {
5 | return (
6 |
7 |
8 |
로딩 중 이에요
9 |
잠시만 기다려 주세요
10 |
11 | )
12 | })
13 |
14 | export default Loading
15 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import useClickOutside from '@hooks/useClickOutside'
2 | import React, { ReactNode, useEffect, useMemo } from 'react'
3 | import { createPortal } from 'react-dom'
4 |
5 | interface IModalProps {
6 | visible: boolean
7 | children: ReactNode
8 | onClose?: () => void
9 | }
10 |
11 | export default function Modal({
12 | visible = false,
13 | children,
14 | onClose,
15 | ...props
16 | }: IModalProps) {
17 | const modalRef = useClickOutside(() => {
18 | onClose && onClose()
19 | })
20 |
21 | const modalElement = useMemo(() => document.createElement('modal'), [])
22 |
23 | useEffect(() => {
24 | document.body.appendChild(modalElement)
25 | return () => {
26 | document.body.removeChild(modalElement)
27 | }
28 | })
29 |
30 | return createPortal(
31 |
36 |
37 |
42 | {children}
43 |
44 |
,
45 | modalElement
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/MoreButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ReactComponent as More } from '@assets/more.svg'
3 |
4 | export default function MoreButton() {
5 | return MoreButton
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 | import { ReactComponent as Record_icon } from '@assets/nav_icons/record_icon.svg'
3 | import { Outlet, useNavigate } from 'react-router-dom'
4 | import NavbarItem from './NavbarItem'
5 | import Loading from './Loading'
6 |
7 | function NavBar() {
8 | const navigate = useNavigate()
9 |
10 | return (
11 | <>
12 | }>
13 |
14 |
15 |
33 | >
34 | )
35 | }
36 |
37 | export const MemoizedNavbar = React.memo(NavBar)
38 |
--------------------------------------------------------------------------------
/src/components/NavbarItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link, useLocation } from 'react-router-dom'
3 | import { ReactComponent as Home_icon } from '@assets/nav_icons/home_icon.svg'
4 | import { ReactComponent as Rank_icon } from '@assets/nav_icons/collect_icon.svg'
5 | import { ReactComponent as MyRecord_icon } from '@assets/nav_icons/myrecord_icon.svg'
6 | import { ReactComponent as Setting_icon } from '@assets/nav_icons/setting_icon.svg'
7 |
8 | interface NavbarItemPropsType {
9 | pageName: string
10 | linkSrc: string
11 | className?: string
12 | }
13 |
14 | export default function NavbarItem({
15 | pageName,
16 | linkSrc,
17 | className,
18 | }: NavbarItemPropsType) {
19 | const containerFormat =
20 | 'group flex h-full w-[54px] flex-col items-center justify-items-center hover:cursor-pointer'
21 | const iconFormat = 'group-hover:fill-primary-2'
22 | const textFormat = 'text-xs group-hover:text-primary-2'
23 |
24 | const { pathname } = useLocation()
25 |
26 | const checkPathWithText = (linkSrc: string) => {
27 | if (pathname === `${linkSrc}`) {
28 | return 'text-primary-2'
29 | }
30 | return 'text-grey-3'
31 | }
32 |
33 | const checkPathWithIconImg = (linkSrc: string) => {
34 | if (pathname === linkSrc) {
35 | return 'fill-primary-2'
36 | }
37 | return 'fill-grey-3'
38 | }
39 |
40 | const navbarIcon = (linkSrc: string) => {
41 | switch (linkSrc) {
42 | case '/':
43 | return (
44 |
48 | )
49 | case '/collect':
50 | return (
51 |
55 | )
56 | case '/myrecord':
57 | return (
58 |
62 | )
63 | case '/setting':
64 | return (
65 |
69 | )
70 | }
71 | }
72 |
73 | return (
74 |
75 | {navbarIcon(linkSrc)}
76 |
81 | {pageName}
82 |
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/ParrentCategoryTab.tsx:
--------------------------------------------------------------------------------
1 | import { CELEBRATION_ID, CONSOLATION_ID } from '@assets/constant/constant'
2 | import React, { Dispatch, SetStateAction } from 'react'
3 | import { parentCategoryID } from 'types/category'
4 |
5 | const MemoizedParentCategoryTab = React.memo(function ParentCategoryTab({
6 | parentCategoryId,
7 | setParentCategoryId,
8 | isModify,
9 | }: {
10 | parentCategoryId: number
11 | setParentCategoryId: Dispatch>
12 | isModify?: boolean
13 | }) {
14 | return (
15 | <>
16 |
17 |
38 |
59 |
60 |
61 | >
62 | )
63 | })
64 |
65 | export default MemoizedParentCategoryTab
66 |
--------------------------------------------------------------------------------
/src/components/RankingItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useNavigate } from 'react-router-dom'
3 | import { parentCategoryID } from 'types/category'
4 | import { IRankingRecordData } from 'types/recordData'
5 | import recordIcons from '@assets/record_icons'
6 | import { CELEBRATION_ID } from '@assets/constant/constant'
7 | import { ReactComponent as Arrow } from '@assets/ranking_btn_arrow.svg'
8 |
9 | interface RankingItemType extends IRankingRecordData {
10 | index: number
11 | parentCategoryId: parentCategoryID
12 | }
13 |
14 | export default function RankingItem({
15 | index,
16 | parentCategoryId,
17 | recordId,
18 | colorName,
19 | title,
20 | writer,
21 | numOfComment,
22 | iconName,
23 | }: RankingItemType) {
24 | const navigate = useNavigate()
25 | const RecordIcon = recordIcons[`${iconName}`]
26 |
27 | const screenAvailWidth = window.screen.availWidth
28 |
29 | const titleRelativeWidth =
30 | screenAvailWidth > 370
31 | ? 'max-w-[45%]'
32 | : screenAvailWidth > 350
33 | ? 'max-w-[40%]'
34 | : 'max-w-[35%]'
35 |
36 | return (
37 | navigate(`/record/${recordId}`)}
40 | >
41 |
42 |
{index}
43 |
46 |
47 |
48 |
49 |
{title}
50 |
51 |
{writer}
52 |
+{numOfComment}
53 |
54 |
55 |
56 |
57 |
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/RankingItemNoData.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ReactComponent as Gift } from '@assets/record_icons/gift.svg'
3 |
4 | export default function RankingItemNoData() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
아직 랭킹이 없어요
12 |
13 | 조금만 기다리면 확인할 수 있어요
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/RecordCard.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction } from 'react'
2 | import recordIcons from '@assets/record_icons'
3 | import { useLocation, useNavigate } from 'react-router-dom'
4 |
5 | interface CardProps {
6 | title: string
7 | recordId: number
8 | colorName: string
9 | type: 'recentRecord' | 'mainRecord'
10 | iconName: string
11 | commentCount: number
12 | isDragging?: boolean
13 | setIsDragging?: Dispatch>
14 | }
15 |
16 | function RecordCard({
17 | recordId,
18 | title,
19 | type,
20 | colorName,
21 | iconName,
22 | commentCount,
23 | isDragging,
24 | setIsDragging,
25 | }: CardProps) {
26 | const ColorName = `bg-${colorName}`
27 | const RecordIcon = recordIcons[`${iconName}`]
28 | const navigate = useNavigate()
29 | const { pathname } = useLocation()
30 |
31 | const handleClickRecord = (recordId: number) => {
32 | if (isDragging && setIsDragging) {
33 | setIsDragging(false)
34 | return
35 | }
36 | navigate(`/record/${recordId}`, { state: { previousUrl: pathname } })
37 | }
38 | return (
39 | handleClickRecord(recordId)}
45 | >
46 |
47 |
48 |
49 |
52 |
댓글 {commentCount}개
53 |
54 | )
55 | }
56 |
57 | export default RecordCard
58 |
--------------------------------------------------------------------------------
/src/components/ScrollTop.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import { useLocation } from 'react-router-dom'
3 |
4 | export default function ScrollTop({ children }: { children: React.ReactNode }) {
5 | const { pathname } = useLocation()
6 |
7 | useEffect(() => {
8 | window.scrollTo(0, 0)
9 | }, [pathname])
10 |
11 | return <>{children}>
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/SmallToast.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement, useEffect, useRef, useState } from 'react'
2 |
3 | interface SmallToastProps {
4 | children: ReactElement
5 | timeLimit?: number
6 | onClose: () => void
7 | }
8 |
9 | function SmallToast({ children, timeLimit = 2, onClose }: SmallToastProps) {
10 | const [times, setTimes] = useState(timeLimit)
11 | const interval: { current: NodeJS.Timeout | undefined } = useRef()
12 | useEffect(() => {
13 | if (times === 0) {
14 | onClose()
15 | } else {
16 | interval.current = setInterval(() => {
17 | setTimes(times - 1)
18 | }, 1000)
19 | return () => clearInterval(interval.current)
20 | }
21 | }, [times])
22 |
23 | return {children}
24 | }
25 |
26 | export default SmallToast
27 |
--------------------------------------------------------------------------------
/src/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ReactComponent as SpinnerIcon } from '@assets/spinner.svg'
3 |
4 | interface ISpinnerProps {
5 | size?: 'small' | 'large' | 'button'
6 | }
7 |
8 | const Spinner = React.memo(function SpinnerComponent({
9 | size = 'small',
10 | }: ISpinnerProps) {
11 | const setSpinnerSize = (size: string) => {
12 | switch (size) {
13 | case 'small':
14 | return 'w-10 h-10'
15 | case 'large':
16 | return 'w-[85px] h-[85px]'
17 | case 'button':
18 | return 'w-[30px] h-[30px]'
19 | }
20 | }
21 |
22 | return
23 | })
24 |
25 | export default Spinner
26 |
--------------------------------------------------------------------------------
/src/components/Toast.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode, useEffect, useRef, useState } from 'react'
2 | import Modal from './Modal'
3 |
4 | interface IToastProps {
5 | visible: boolean
6 | message: ReactNode
7 | timeLimit?: number
8 | onClose: () => void
9 | hasSecondMessage?: boolean
10 | size?: 'big' | 'basic'
11 | }
12 |
13 | function Toast({
14 | visible,
15 | message,
16 | timeLimit = 2,
17 | onClose,
18 | hasSecondMessage = true,
19 | size = 'basic',
20 | }: IToastProps) {
21 | const [times, setTimes] = useState(timeLimit)
22 | const interval: { current: NodeJS.Timeout | undefined } = useRef()
23 | useEffect(() => {
24 | if (times === 0) {
25 | onClose()
26 | } else {
27 | interval.current = setInterval(() => {
28 | setTimes(times - 1)
29 | }, 1000)
30 | return () => clearInterval(interval.current)
31 | }
32 | }, [times])
33 |
34 | return (
35 |
36 |
41 |
42 | {message}
43 |
44 | {hasSecondMessage && (
45 |
46 | {times}초 뒤에 사라집니다.
47 |
48 | )}
49 |
50 |
51 | )
52 | }
53 |
54 | export default Toast
55 |
--------------------------------------------------------------------------------
/src/hooks/useCheckMobile.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export const useCheckMobile = () => {
4 | const [isMobile, setIsMobile] = useState(false)
5 |
6 | useEffect(() => {
7 | function detectMobileDevice(agent: string): boolean {
8 | const mobileRegex = [
9 | /Android/i,
10 | /iPhone/i,
11 | /iPad/i,
12 | /iPod/i,
13 | /BlackBerry/i,
14 | /Windows Phone/i,
15 | ]
16 |
17 | return mobileRegex.some((mobile) => agent.match(mobile))
18 | }
19 |
20 | setIsMobile(detectMobileDevice(window.navigator.userAgent))
21 | }, [])
22 |
23 | return { isMobile }
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | const events = ['mousedown', 'touchstart']
4 |
5 | const useClickOutside = (
6 | handler: (e: Event) => void
7 | ) => {
8 | // 파라미터로 받는 것 : 바깥 부분을 클릭했을 때 실행 되는 이벤트
9 | const ref = useRef(null)
10 |
11 | useEffect(() => {
12 | const element = ref.current
13 | if (!element) return
14 |
15 | const handleEvent = (e: Event) => {
16 | // 이벤틑 타켓이 해당 엘리먼트에 포함되어 있는지
17 | // 포함되어 있지 않으면 이벤트를 실행
18 | !element.contains(e.target as Node) && handler(e)
19 | }
20 |
21 | for (const eventName of events) {
22 | document.addEventListener(eventName, handleEvent)
23 | }
24 |
25 | return () => {
26 | for (const eventName of events) {
27 | document.removeEventListener(eventName, handleEvent)
28 | }
29 | }
30 | }, [ref, handler])
31 |
32 | return ref
33 | }
34 |
35 | export default useClickOutside
36 |
--------------------------------------------------------------------------------
/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import useTimeoutFunc from './useTimeoutFunc'
3 |
4 | const useDebounce = (func: () => void, delay: number, deps: Array) => {
5 | const [run, clear] = useTimeoutFunc(func, delay)
6 |
7 | useEffect(run, deps)
8 |
9 | return clear
10 | }
11 |
12 | export default useDebounce
13 |
--------------------------------------------------------------------------------
/src/hooks/useImageSwipe.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export const useImageSwipe = (imageUrl: string[]) => {
4 | const [haveNext, setHaveNext] = useState(false)
5 | const [havePrev, setHavePrev] = useState(false)
6 | const [imageState, setImageState] = useState(0)
7 |
8 | const next = () => {
9 | setImageState((prev) => prev + 1)
10 | }
11 | const prev = () => {
12 | setImageState((prev) => prev - 1)
13 | }
14 |
15 | useEffect(() => {
16 | if (imageUrl[0]) {
17 | if (imageState === 0) {
18 | setHaveNext(true)
19 | setHavePrev(false)
20 | }
21 | if (imageState === imageUrl.length) {
22 | setHaveNext(false)
23 | setHavePrev(true)
24 | }
25 | if (imageState !== 0) setHavePrev(true)
26 | if (imageState !== imageUrl.length) setHaveNext(true)
27 | }
28 | }, [imageUrl, imageState])
29 |
30 | return { haveNext, havePrev, next, prev, imageState }
31 | }
32 |
--------------------------------------------------------------------------------
/src/hooks/useIntersectionObserver.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react'
2 |
3 | type IntersectHandler = (
4 | entry: IntersectionObserverEntry,
5 | observer: IntersectionObserver
6 | ) => void
7 |
8 | export const useIntersect = (
9 | onIntersect: IntersectHandler,
10 | options?: IntersectionObserverInit
11 | ) => {
12 | const ref = useRef(null)
13 | const callback = useCallback(
14 | (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
15 | entries.forEach((entry) => {
16 | if (entry.isIntersecting) onIntersect(entry, observer)
17 | })
18 | },
19 | [onIntersect]
20 | )
21 |
22 | useEffect(() => {
23 | if (!ref.current) return
24 | const observer = new IntersectionObserver(callback, options)
25 | observer.observe(ref.current)
26 | return () => observer.disconnect()
27 | }, [ref, options, callback])
28 |
29 | return ref
30 | }
31 |
--------------------------------------------------------------------------------
/src/hooks/useSwipe.ts:
--------------------------------------------------------------------------------
1 | import { MutableRefObject, useState } from 'react'
2 |
3 | const useSwipe = (ref: MutableRefObject) => {
4 | const [isDragging, setIsDragging] = useState(false)
5 | let pos = { top: 0, left: 0, x: 0, y: 0 }
6 |
7 | const handleMouseDown = (e: React.MouseEvent) => {
8 | e.preventDefault()
9 | pos = {
10 | left: ref.current.scrollLeft,
11 | top: ref.current.scrollTop,
12 | x: e.clientX,
13 | y: e.clientY,
14 | }
15 |
16 | document.addEventListener('mousemove', handleMouseMove)
17 | document.addEventListener('mouseup', handleMouseUp)
18 | }
19 |
20 | const handleMouseMove = (e: MouseEvent) => {
21 | if (!isDragging) {
22 | setIsDragging(true)
23 | }
24 | const dx = e.clientX - pos.x
25 | const dy = e.clientY - pos.y
26 |
27 | ref.current.scrollTop = pos.top - dy
28 | ref.current.scrollLeft = pos.left - dx
29 |
30 | ref.current.style.cursor = 'grabbing'
31 | ref.current.style.userSelect = 'none'
32 | }
33 |
34 | const handleMouseUp = (e: MouseEvent) => {
35 | if (isDragging) {
36 | e.stopPropagation()
37 | setIsDragging(false)
38 | }
39 | document.removeEventListener('mousemove', handleMouseMove)
40 | document.removeEventListener('mouseup', handleMouseUp)
41 |
42 | ref.current.style.cursor = 'pointer'
43 | ref.current.style.removeProperty('user-select')
44 | }
45 |
46 | return { handleMouseDown, isDragging, setIsDragging }
47 | }
48 |
49 | export default useSwipe
50 |
--------------------------------------------------------------------------------
/src/hooks/useThrottle.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react'
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
4 | export const useThrottle = (
5 | callback: (...params: T) => void,
6 | delay: number
7 | ) => {
8 | const timer = useRef | null>(null)
9 |
10 | return (...params: T) => {
11 | if (!timer.current) {
12 | callback(...params)
13 | timer.current = setTimeout(() => {
14 | timer.current = null
15 | }, delay)
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useTimeoutFunc.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react'
2 |
3 | type TimeoutFuncType = (
4 | func: () => void,
5 | delay: number
6 | ) => [run: () => void, clear: () => void]
7 |
8 | const useTimeoutFn: TimeoutFuncType = (func, delay) => {
9 | const timeoutId = useRef()
10 | const callback = useRef(func)
11 |
12 | useEffect(() => {
13 | callback.current = func
14 | }, [func])
15 |
16 | const run = useCallback(() => {
17 | timeoutId.current && clearTimeout(timeoutId.current)
18 |
19 | timeoutId.current = setTimeout(() => {
20 | callback.current()
21 | }, delay)
22 | }, [delay])
23 |
24 | const clear = useCallback(() => {
25 | timeoutId.current && clearTimeout(timeoutId.current)
26 | }, [])
27 |
28 | useEffect(() => clear, [clear])
29 |
30 | return [run, clear]
31 | }
32 |
33 | export default useTimeoutFn
34 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | * {
6 | box-sizing: border-box;
7 | -ms-overflow-style: none; /* Hide scrollbar for IE and Edge */
8 | scrollbar-width: none; /* Hide scrollbar for Firefox */
9 | font-family: San Francisco;
10 | }
11 | /* Hide scrollbar for Chrome, Safari and Opera */
12 | ::-webkit-scrollbar {
13 | display: none;
14 | }
15 |
16 | html,
17 | body,
18 | div,
19 | span,
20 | applet,
21 | object,
22 | iframe,
23 | h1,
24 | h2,
25 | h3,
26 | h4,
27 | h5,
28 | h6,
29 | p,
30 | blockquote,
31 | pre,
32 | a,
33 | abbr,
34 | acronym,
35 | address,
36 | big,
37 | cite,
38 | code,
39 | del,
40 | dfn,
41 | em,
42 | img,
43 | ins,
44 | kbd,
45 | q,
46 | s,
47 | samp,
48 | small,
49 | strike,
50 | strong,
51 | sub,
52 | sup,
53 | tt,
54 | var,
55 | b,
56 | u,
57 | i,
58 | center,
59 | dl,
60 | dt,
61 | dd,
62 | ol,
63 | ul,
64 | li,
65 | fieldset,
66 | form,
67 | label,
68 | legend,
69 | table,
70 | caption,
71 | tbody,
72 | tfoot,
73 | thead,
74 | tr,
75 | th,
76 | td,
77 | article,
78 | aside,
79 | canvas,
80 | details,
81 | embed,
82 | figure,
83 | figcaption,
84 | footer,
85 | header,
86 | hgroup,
87 | menu,
88 | nav,
89 | output,
90 | ruby,
91 | section,
92 | summary,
93 | time,
94 | mark,
95 | audio,
96 | video {
97 | margin: 0;
98 | padding: 0;
99 | border: 0;
100 | font-size: 100%;
101 | vertical-align: baseline;
102 | text-decoration: none;
103 | }
104 |
105 | /* HTML5 display-role reset for older browsers */
106 | article,
107 | aside,
108 | details,
109 | figcaption,
110 | figure,
111 | footer,
112 | header,
113 | hgroup,
114 | menu,
115 | nav,
116 | section {
117 | display: block;
118 | }
119 |
120 | body {
121 | line-height: 1;
122 | }
123 |
124 | ol,
125 | ul {
126 | list-style: none;
127 | }
128 |
129 | blockquote,
130 | q {
131 | quotes: none;
132 | }
133 |
134 | blockquote:before,
135 | blockquote:after,
136 | q:before,
137 | q:after {
138 | content: '';
139 | content: none;
140 | }
141 |
142 | table {
143 | border-collapse: collapse;
144 | border-spacing: 0;
145 | }
146 |
147 | div {
148 | border-style: solid;
149 | border-width: 0;
150 | }
151 |
152 | button,
153 | input,
154 | textarea {
155 | border: inherit;
156 | }
157 |
158 | .slick-slide {
159 | display: flex !important;
160 | justify-content: center !important;
161 | }
162 |
163 | .slick-current .slick-select {
164 | width: 186px !important;
165 | background-color: #efe9fb;
166 | }
167 |
168 | .slick-current .slick-select span {
169 | color: #703cde;
170 | }
171 |
172 | .line-clamp {
173 | display: -webkit-box;
174 | -webkit-line-clamp: 3;
175 | -webkit-box-orient: vertical;
176 | }
177 |
178 | body {
179 | min-height: -webkit-fill-available;
180 | }
181 |
182 | .year-slider {
183 | display: block;
184 | }
185 |
186 | .month-slider {
187 | display: block;
188 | }
189 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
7 | root.render(
8 |
9 |
10 |
11 | )
12 |
--------------------------------------------------------------------------------
/src/pages/AddRecord/AddRecordCategory.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import { useRecoilState } from 'recoil'
3 | import { formDataAtom } from '@store/atom'
4 | import Category from '@components/Category'
5 | import { parentCategoryID } from 'types/category'
6 |
7 | function AddRecordCategory({
8 | parentCategoryId,
9 | recordCategory,
10 | isModify,
11 | }: {
12 | parentCategoryId: parentCategoryID
13 | recordCategory: number
14 | isModify: boolean
15 | }) {
16 | const [formData, setFormData] = useRecoilState(formDataAtom)
17 | const [choosedCategoryId, setChoosedCategoryId] = useState(3)
18 |
19 | useEffect(() => {
20 | setFormData({
21 | ...formData,
22 | selectedCategory: choosedCategoryId,
23 | })
24 | }, [choosedCategoryId])
25 |
26 | useEffect(() => {
27 | if (isModify) {
28 | setChoosedCategoryId(recordCategory)
29 | }
30 | }, [])
31 | return (
32 |
37 |
44 |
45 | )
46 | }
47 |
48 | export default AddRecordCategory
49 |
--------------------------------------------------------------------------------
/src/pages/AddRecord/AddRecordColor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import { ReactComponent as Check } from '@assets/check.svg'
3 | import { useRecoilState } from 'recoil'
4 | import { formDataAtom } from '@store/atom'
5 | import {
6 | ADD_RECORD_COLORS,
7 | colorSourceType,
8 | } from '@assets/constant/RecordColors'
9 | import { parentCategoryID } from 'types/category'
10 |
11 | interface Props {
12 | recordColor: string
13 | parentCategoryId: parentCategoryID
14 | }
15 |
16 | function AddRecordColor({ recordColor, parentCategoryId }: Props) {
17 | const [colors, setColors] = useState(ADD_RECORD_COLORS)
18 | const [formData, setFormData] = useRecoilState(formDataAtom)
19 |
20 | useEffect(() => {
21 | setColors(
22 | colors.map((color: colorSourceType, index: number) => {
23 | if (index === 0) {
24 | return { ...color, choosed: true }
25 | }
26 | return { ...color, choosed: false }
27 | })
28 | )
29 |
30 | if (recordColor) {
31 | return setColors(
32 | ADD_RECORD_COLORS.map((color) => {
33 | return { ...color, choosed: color.src.indexOf(recordColor) !== -1 }
34 | })
35 | )
36 | }
37 | }, [parentCategoryId])
38 |
39 | const handleChooseCurrentColor = (index: number): void => {
40 | const changeCurrent = colors.map((color) => ({
41 | ...color,
42 | choosed: color.id === index,
43 | }))
44 | const colorSrc = colors[index].src
45 | setFormData({
46 | ...formData,
47 | selectedColor: makeColorSrcToColor(colorSrc),
48 | })
49 | setColors(changeCurrent)
50 | }
51 |
52 | const makeColorSrcToColor = (colorSrc: string) => {
53 | return colorSrc.slice(colorSrc.indexOf('-') + 1)
54 | }
55 |
56 | return (
57 |
58 | {colors.map((color, index) => {
59 | return (
60 |
61 |
handleChooseCurrentColor(index)}
64 | />
65 | {color.choosed && (
66 |
70 | )}
71 |
72 | )
73 | })}
74 |
75 | )
76 | }
77 |
78 | export default AddRecordColor
79 |
--------------------------------------------------------------------------------
/src/pages/AddRecord/AddRecordInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'
2 | import {
3 | CELEBRATION_ID,
4 | INPUT_DETAILS,
5 | RECORD_TITLE_MAX_LENGTH,
6 | } from '@assets/constant/constant'
7 | import { IsInputsHasValueType } from './AddRecord'
8 | import { parentCategoryID } from 'types/category'
9 |
10 | interface Props {
11 | setIsInputsHasValue: Dispatch
>
12 | isInputsHasValue: IsInputsHasValueType
13 | setIsInputFocus: Dispatch>
14 | recordTitle: string
15 | setRecordTitle: Dispatch>
16 | parentCategoryId: parentCategoryID
17 | isModify?: boolean
18 | modifyTitle: string
19 | }
20 |
21 | function AddRecordInput({
22 | setIsInputsHasValue,
23 | isInputsHasValue,
24 | setIsInputFocus,
25 | recordTitle,
26 | setRecordTitle,
27 | parentCategoryId,
28 | isModify,
29 | modifyTitle,
30 | }: Props) {
31 | const [isFocus, setIsFocus] = useState(false)
32 | const PLACEHOLDER_MESSAGE =
33 | parentCategoryId === CELEBRATION_ID
34 | ? 'ex) 5월 5일 내 생일'
35 | : 'ex) 오늘 우울해요'
36 |
37 | useEffect(() => {
38 | setRecordTitle('')
39 | }, [parentCategoryId])
40 |
41 | const handleChange = (e: React.ChangeEvent) => {
42 | const inputValueLength = e.target.value.length
43 | if (inputValueLength > INPUT_DETAILS.MAX_INPUT_TYPING) {
44 | return
45 | }
46 | if (inputValueLength > INPUT_DETAILS.MIN_TYPING) {
47 | setIsInputsHasValue({ ...isInputsHasValue, input: true })
48 | }
49 | if (inputValueLength === INPUT_DETAILS.MIN_TYPING) {
50 | setIsInputsHasValue({ ...isInputsHasValue, input: false })
51 | }
52 | setRecordTitle(e.target.value)
53 | }
54 |
55 | const handleFocus = () => {
56 | setIsInputFocus(true)
57 | setIsFocus(true)
58 | }
59 | const handleBlur = () => {
60 | setIsInputFocus(false)
61 | setIsFocus(false)
62 | }
63 |
64 | return (
65 |
70 | handleChange(e)}
77 | type="text"
78 | value={modifyTitle ? modifyTitle : recordTitle}
79 | maxLength={RECORD_TITLE_MAX_LENGTH}
80 | />
81 | {`${recordTitle.length}/${INPUT_DETAILS.MAX_INPUT_TYPING}`}
82 |
83 | )
84 | }
85 |
86 | export default AddRecordInput
87 |
--------------------------------------------------------------------------------
/src/pages/AddRecord/AddRecordTextArea.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction, useEffect } from 'react'
2 | import { CELEBRATION_ID, INPUT_DETAILS } from '@assets/constant/constant'
3 | import { IsInputsHasValueType } from './AddRecord'
4 | import { parentCategoryID } from 'types/category'
5 |
6 | type userProps = {
7 | recordContent: string
8 | setRecordContent: Dispatch>
9 | currentRecordType: parentCategoryID
10 | setIsInputsHasValue: Dispatch>
11 | isInputsHasValue: IsInputsHasValueType
12 | setIsInputFocus: Dispatch>
13 | modifyTitle: string
14 | }
15 |
16 | function AddRecordTextArea({
17 | setIsInputsHasValue,
18 | isInputsHasValue,
19 | currentRecordType,
20 | setIsInputFocus,
21 | recordContent,
22 | setRecordContent,
23 | modifyTitle,
24 | }: userProps) {
25 | const PLACEHOLDER_MESSAGE = {
26 | celebration: 'ex) 오늘은 나의 생일이에요! 모두 축하해주세요!',
27 | consolation: 'ex) 오늘은 기분이 우울하네요. 저를 위로해주세요',
28 | }
29 |
30 | useEffect(() => {
31 | setRecordContent(
32 | modifyTitle
33 | ? recordContent.replaceAll(/(
|
|
)/g, '\r\n')
34 | : ''
35 | )
36 | }, [currentRecordType])
37 |
38 | const handleChangeTextArea = (
39 | e: React.ChangeEvent
40 | ): void => {
41 | const inputValueLength = e.target.value.length
42 | if (inputValueLength > INPUT_DETAILS.MAX_TEXTAREA_TYPING) {
43 | return
44 | }
45 | if (inputValueLength > 0) {
46 | setIsInputsHasValue({ ...isInputsHasValue, textArea: true })
47 | }
48 | if (inputValueLength === 0) {
49 | setIsInputsHasValue({ ...isInputsHasValue, textArea: false })
50 | }
51 | setRecordContent(e.target.value)
52 | }
53 |
54 | return (
55 |
72 | )
73 | }
74 |
75 | export default AddRecordTextArea
76 |
--------------------------------------------------------------------------------
/src/pages/AddRecord/AddRecordTitle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function AddRecordTitle({
4 | title,
5 | isModify,
6 | }: {
7 | title: string
8 | isModify?: boolean
9 | }) {
10 | return (
11 |
16 | {title}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/Collect/Collect.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from 'react'
2 | import { usePreviousUrlWithStorage } from '@react-query/hooks/usePreviousUrlWithStorage'
3 | import RecentRecord from './RecentRecord'
4 | import { ReactComponent as ScrollTop } from '@assets/collect_page_icon/scrollTop.svg'
5 | import CollectRanking from './CollectRanking'
6 | import PeriodModal from './PeriodModal'
7 | import { keyOfRankingPeriod } from '@assets/constant/ranking'
8 | import { useRecoilState } from 'recoil'
9 | import { rankingPeriodAtom } from '@store/collectPageAtom'
10 |
11 | export default function Collect() {
12 | const collectRef: React.RefObject = useRef(null)
13 | const [isScroll, setIsScroll] = useState(false)
14 |
15 | const [rankingPeriod, setRankingPeriod] =
16 | useRecoilState(rankingPeriodAtom)
17 | const [openModal, setOpenModal] = useState(false)
18 |
19 | const NAVBAR_HEIGHT = 60
20 | const SCROLL_BUTTON_SIZE = 39
21 | const SCROLL_BUTTON_POSITION_Y = 12
22 | const VIEWPORT_WIDTH = (window.innerWidth > 420 ? 420 : window.innerWidth) / 2
23 | const SCROLL_BUTTON_POSITION_X = 16
24 |
25 | const handleScrollTop = () => {
26 | if (collectRef.current !== null) {
27 | collectRef.current.scrollTo({
28 | top: 0,
29 | left: 0,
30 | behavior: 'smooth',
31 | })
32 | }
33 | }
34 |
35 | const handleScroll = () => {
36 | if (collectRef.current?.scrollTop === 0) {
37 | return setIsScroll(false)
38 | }
39 | if (isScroll) return
40 | setIsScroll(true)
41 | }
42 |
43 | const getLeftValue = () => {
44 | return `calc(50% + ${
45 | VIEWPORT_WIDTH - SCROLL_BUTTON_SIZE - SCROLL_BUTTON_POSITION_X
46 | }px)`
47 | }
48 |
49 | const getTopValue = () => {
50 | return `${
51 | window.innerHeight -
52 | NAVBAR_HEIGHT -
53 | SCROLL_BUTTON_SIZE -
54 | SCROLL_BUTTON_POSITION_Y
55 | }px`
56 | }
57 |
58 | usePreviousUrlWithStorage('sessionStorage')
59 | return (
60 |
65 | {openModal && (
66 |
70 | )}
71 |
72 | {isScroll && (
73 | handleScrollTop()}
75 | style={{ left: getLeftValue(), top: getTopValue() }}
76 | className={`fixed z-[20] cursor-pointer`}
77 | />
78 | )}
79 |
86 |
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/src/pages/Collect/PeriodModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction } from 'react'
2 | import useClickOutside from '@hooks/useClickOutside'
3 | import { keyOfRankingPeriod, RANKINGPERIOD } from '@assets/constant/ranking'
4 |
5 | export default function PeriodModal({
6 | setRankingPeriod,
7 | setOpenModal,
8 | }: {
9 | setRankingPeriod: Dispatch>
10 | setOpenModal: Dispatch>
11 | }) {
12 | const modalRef = useClickOutside(() => {
13 | setOpenModal(false)
14 | })
15 |
16 | const handleClickBtn = (key: keyOfRankingPeriod) => {
17 | setRankingPeriod(key)
18 | setOpenModal(false)
19 | }
20 |
21 | return (
22 | <>
23 |
27 |
28 | {Object.entries(RANKINGPERIOD).map(([key, value]) => (
29 |
30 | handleClickBtn(key as keyOfRankingPeriod)}>
31 |
32 |
33 | {value}
34 |
35 | |
36 |
37 |
38 | ))}
39 |
40 |
41 |
44 | >
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/pages/Collect/Timer.tsx:
--------------------------------------------------------------------------------
1 | import { RESET_TIME } from '@assets/constant/collect'
2 | import { getTimeGap } from '@utils/getTimeGap'
3 | import { SessionStorage } from '@utils/sessionStorage'
4 | import React from 'react'
5 |
6 | function Timer() {
7 | const getTimer = Number(SessionStorage.get('resetTime')) as number
8 | const timeGapByTimer = getTimeGap(getTimer) as number
9 | const REMAIN_TIME = RESET_TIME - timeGapByTimer
10 | const fullTimeDivide60 = REMAIN_TIME % 60
11 |
12 | return (
13 |
14 |
15 | {REMAIN_TIME / 60 >= 1 ? `0${Math.floor(REMAIN_TIME / 60)}` : '00'} :{' '}
16 |
17 |
{`${Math.floor(
18 | fullTimeDivide60 / 10
19 | )}`}
20 |
{fullTimeDivide60 % 10}
21 |
22 | )
23 | }
24 |
25 | export default Timer
26 |
--------------------------------------------------------------------------------
/src/pages/DetailRecord/EditModal.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@components/Button'
2 | import React, { Dispatch, SetStateAction } from 'react'
3 | import { ReactComponent as Pin } from '@assets/pin.svg'
4 | import useClickOutside from '@hooks/useClickOutside'
5 | import { useNavigate } from 'react-router-dom'
6 | import { LocalStorage } from '@utils/localStorage'
7 |
8 | export default function EditModal({
9 | setEditModalState,
10 | setIsDelete,
11 | POST_ID,
12 | }: {
13 | setEditModalState: Dispatch>
14 | setIsDelete: Dispatch>
15 | POST_ID: string
16 | }) {
17 | const editRef = useClickOutside(() => {
18 | setEditModalState(false)
19 | })
20 | const navigate = useNavigate()
21 |
22 | const handleClickDeleteButton = () => {
23 | setIsDelete(true)
24 | setEditModalState(false)
25 | }
26 |
27 | const handleClickModifyButton = () => {
28 | LocalStorage.set('modifyMode', 'true')
29 | LocalStorage.set('postId', `${POST_ID}`)
30 | navigate('/record/add')
31 | }
32 |
33 | return (
34 | <>
35 |
39 |
42 |
43 |
44 |
51 |
52 |
53 |
56 |
57 |
58 |
59 |
62 |
63 |
64 |
67 | >
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/src/pages/DetailRecord/ImageContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import recordIcons from '@assets/record_icons'
4 | import { ReactComponent as Left_Arrow_icon } from '@assets/ImageContainer/left_Arrow.svg'
5 | import { ReactComponent as Right_Arrow_icon } from '@assets/ImageContainer/right_Arrow.svg'
6 | import { useImageSwipe } from '@hooks/useImageSwipe'
7 |
8 | interface Iprops {
9 | background_color: string
10 | iconName: string
11 | imageUrls: string[]
12 | }
13 | export default function ImageContainer({
14 | background_color,
15 | iconName,
16 | imageUrls,
17 | }: Iprops) {
18 | const { haveNext, havePrev, next, prev, imageState } =
19 | useImageSwipe(imageUrls)
20 | const RecordIcon = recordIcons[`${iconName}`]
21 |
22 | return (
23 |
24 |
27 | {imageState === 0 && iconName !== '' && (
28 |
29 | )}
30 |
31 | {imageState !== 0 && (
32 |
33 |

38 |
39 | )}
40 | {haveNext && imageUrls?.length !== 0 && (
41 |
47 | )}
48 | {havePrev && (
49 |
55 | )}
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/pages/DetailRecord/InputAddImage.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction } from 'react'
2 | import { ReactComponent as Camera } from '@assets/camera.svg'
3 | import { ReactComponent as Plus } from '@assets/plus.svg'
4 | import { RECORD_DETAIL_INPUT_IMAGE_HEIGHT } from '@assets/constant/constant'
5 | import { checkFileSize } from '@utils/fileSize'
6 |
7 | interface AddImageType {
8 | image: string
9 | setImage: Dispatch>
10 | setImageFile: Dispatch>
11 | setInputSectionHeight: Dispatch>
12 | setIsOpenToast: Dispatch>
13 | }
14 |
15 | export default function InputAddImage({
16 | image,
17 | setImage,
18 | setImageFile,
19 | setInputSectionHeight,
20 | setIsOpenToast,
21 | }: AddImageType) {
22 | const handleSelectImageFile = (e: React.ChangeEvent) => {
23 | if (checkFileSize(e, () => setIsOpenToast(true))) {
24 | return
25 | }
26 |
27 | encodeFile((e.target.files as FileList)[0])
28 |
29 | setImage(e.target.value)
30 | setImageFile((e.target.files as FileList)[0])
31 |
32 | setInputSectionHeight((prev) => prev + RECORD_DETAIL_INPUT_IMAGE_HEIGHT)
33 | e.target.value = ''
34 | }
35 |
36 | const encodeFile = (fileBlob: File) => {
37 | const reader = new FileReader()
38 | reader.readAsDataURL(fileBlob)
39 | return new Promise((resolve, reject) => {
40 | reader.onload = () => {
41 | try {
42 | setImage(reader.result as string)
43 | resolve()
44 | } catch (error) {
45 | reject(error)
46 | }
47 | }
48 | })
49 | }
50 |
51 | return (
52 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/pages/DetailRecord/InputSnackBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction } from 'react'
2 | import { useRecoilValue, useResetRecoilState } from 'recoil'
3 | import { ReactComponent as CloseIcon } from '@assets/detail_page_icon/Close.svg'
4 | import { DetailPageInputMode } from '@store/detailPageAtom'
5 |
6 | interface Iprops {
7 | setText: Dispatch>
8 | setImage: Dispatch>
9 | setImageFile: Dispatch>
10 | }
11 |
12 | export default function InputSnackBar({
13 | setText,
14 | setImage,
15 | setImageFile,
16 | }: Iprops) {
17 | const inputMode = useRecoilValue(DetailPageInputMode)
18 | const resetInputMode = useResetRecoilState(DetailPageInputMode)
19 | return (
20 | <>
21 | {(inputMode.mode === 'nestedReply' || inputMode.mode === 'update') && (
22 |
23 |
24 | {inputMode.mode === 'nestedReply' ? '답글 작성중...' : '수정중...'}
25 |
26 |
37 |
38 | )}
39 | >
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/pages/DetailRecord/InputTextarea.tsx:
--------------------------------------------------------------------------------
1 | import { RECORD_DETAIL_INPUT_HEIGHT_WITHOUT_TEXTAREA } from '@assets/constant/constant'
2 | import Alert from '@components/Alert'
3 | import { useUser } from '@react-query/hooks/useUser'
4 | import { DetailPageInputMode } from '@store/detailPageAtom'
5 | import { LocalStorage } from '@utils/localStorage'
6 | import React, {
7 | Dispatch,
8 | SetStateAction,
9 | useCallback,
10 | useEffect,
11 | useState,
12 | } from 'react'
13 | import { useNavigate } from 'react-router-dom'
14 | import { useRecoilValue, useResetRecoilState } from 'recoil'
15 |
16 | interface InputTextareaProps {
17 | textareaRef: React.RefObject
18 | text: string
19 | setText: Dispatch>
20 | setInputSectionHeight: Dispatch>
21 | recordIdParams?: string
22 | }
23 | export default function InputTextarea({
24 | textareaRef,
25 | text,
26 | setText,
27 | setInputSectionHeight,
28 | recordIdParams,
29 | }: InputTextareaProps) {
30 | const screenAvailWidth = window.screen.availWidth
31 |
32 | useEffect(() => {
33 | if (screenAvailWidth > 340) {
34 | setInputPlaceholder('따뜻한 마음을 남겨주세요. (100자 이내)')
35 | }
36 | }, [])
37 |
38 | const navigate = useNavigate()
39 | const inputMode = useRecoilValue(DetailPageInputMode)
40 | const resetInputMode = useResetRecoilState(DetailPageInputMode)
41 |
42 | const { user, isLoading } = useUser()
43 | const [inputPlaceholder, setInputPlaceholder] =
44 | useState('따뜻한 마음을 남겨주세요')
45 |
46 | const handleInputFocus = () => {
47 | if (!user) {
48 | setIsCheckedUser(true)
49 | }
50 | }
51 |
52 | const [isCheckedUser, setIsCheckedUser] = useState(false)
53 | const [isAnonymousUser, setIsAnonymousUser] = useState(false)
54 |
55 | const handleCancelSingUp = () => {
56 | setIsCheckedUser(false)
57 | setIsAnonymousUser(true)
58 | }
59 |
60 | const handleConfirmSignUp = () => {
61 | LocalStorage.set('redirectUrl', `/record/${recordIdParams}`)
62 | resetInputMode()
63 | navigate('/login')
64 | }
65 |
66 | useEffect(() => {
67 | if (inputMode.mode === 'nestedReply' && !user) {
68 | setIsCheckedUser(true)
69 | }
70 | }, [inputMode.mode])
71 |
72 | useEffect(() => {
73 | if (isAnonymousUser === true) {
74 | textareaRef.current?.focus()
75 | }
76 | }, [isAnonymousUser])
77 |
78 | const handleResizeHeight = useCallback(() => {
79 | if (textareaRef.current !== null) {
80 | textareaRef.current.style.height = 'auto'
81 | textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
82 | setInputSectionHeight(
83 | RECORD_DETAIL_INPUT_HEIGHT_WITHOUT_TEXTAREA +
84 | textareaRef.current.scrollHeight
85 | )
86 | }
87 | }, [])
88 |
89 | return (
90 | <>
91 |
35 | }
36 | subMessage={<>로그인하고 추억을 공유해보세요.>}
37 | cancelMessage="닫기"
38 | confirmMessage="로그인"
39 | onClose={() => navigate('/')}
40 | onCancel={() => navigate('/')}
41 | onConfirm={() => redirectPage('/record/add')}
42 | />
43 | )
44 | }
45 |
46 | if (!user && route?.indexOf('myrecord') !== -1) {
47 | return (
48 |
52 | 비회원은 레코드를
53 |
54 | 확인 할 수 없어요
55 |
56 | }
57 | subMessage={<>로그인하고 추억을 공유해보세요.>}
58 | cancelMessage="닫기"
59 | confirmMessage="로그인"
60 | onClose={() => navigate('/')}
61 | onCancel={() => navigate('/')}
62 | onConfirm={() => redirectPage('/myrecord')}
63 | />
64 | )
65 | }
66 |
67 | if (!user && route?.indexOf('withdraw') !== -1) {
68 | return (
69 |
73 | 비회원은 계정을
74 |
75 | 탈퇴 할 수 없어요
76 |
77 | }
78 | cancelMessage="닫기"
79 | confirmMessage="로그인"
80 | onClose={() => navigate('/')}
81 | onCancel={() => navigate('/')}
82 | onConfirm={() => redirectPage('/setting')}
83 | />
84 | )
85 | }
86 |
87 | return children
88 | }
89 |
90 | export default ProtectedRoute
91 |
--------------------------------------------------------------------------------
/src/store/atom.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil'
2 |
3 | export const formDataAtom = atom({
4 | key: 'formData',
5 | default: {
6 | selectedCategory: 3,
7 | selectedColor: 'icon-purple',
8 | selectedIcon: 'gift',
9 | },
10 | })
11 |
12 | export const scrollTarget = atom<{
13 | scrollReset: boolean
14 | commentId: number | null
15 | }>({
16 | key: 'scrollToReply',
17 | default: { scrollReset: false, commentId: null },
18 | })
19 |
--------------------------------------------------------------------------------
/src/store/collectPageAtom.ts:
--------------------------------------------------------------------------------
1 | import { keyOfRankingPeriod } from './../assets/constant/ranking'
2 | import { CELEBRATION_ID } from './../assets/constant/constant'
3 | import { parentCategoryID } from './../types/category'
4 | import { atom } from 'recoil'
5 |
6 | export const parentCategoryIdAtomColletPage = atom({
7 | key: 'parentCategoryIdCollectPage',
8 | default: CELEBRATION_ID,
9 | })
10 |
11 | export const subCategoryIdAtomCollectPage = atom({
12 | key: 'subCategoryIdAtomCollectPage',
13 | default: parentCategoryIdAtomColletPage,
14 | })
15 |
16 | export const rankingPeriodAtom = atom({
17 | key: 'rankingPeriodAtom',
18 | default: 'TOTAL',
19 | })
20 |
--------------------------------------------------------------------------------
/src/store/detailPageAtom.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil'
2 |
3 | export const DetailPageInputMode = atom<{
4 | mode: 'reply' | 'nestedReply' | 'update'
5 | recordId: string | undefined
6 | parentId: number | string
7 | }>({
8 | key: 'DetailPageInputMode',
9 | default: {
10 | mode: 'reply',
11 | recordId: '',
12 | parentId: '',
13 | },
14 | })
15 |
16 | export const nestedReplyState = atom({
17 | key: 'nestedReplyState',
18 | default: {
19 | commentId: 0,
20 | state: false,
21 | },
22 | })
23 |
24 | export const modifyComment = atom<{
25 | commentId: number
26 | content: string
27 | imageUrl: string
28 | }>({
29 | key: 'modifyComment',
30 | default: {
31 | commentId: 0,
32 | content: '',
33 | imageUrl: '',
34 | },
35 | })
36 |
37 | export const checkFromDetailPage = atom({
38 | key: 'fromDetailPage',
39 | default: false,
40 | })
41 |
--------------------------------------------------------------------------------
/src/store/mainPageAtom.ts:
--------------------------------------------------------------------------------
1 | import { CELEBRATION_ID } from '@assets/constant/constant'
2 | import { atom } from 'recoil'
3 | import { parentCategoryID } from 'types/category'
4 |
5 | export const parentCategoryIdAtomMainPage = atom({
6 | key: 'parentCategoryIdMainPage',
7 | default: CELEBRATION_ID,
8 | })
9 |
10 | export const subCategoryIdAtomMainPage = atom({
11 | key: 'subCategoryIdAtomMainPage',
12 | default: parentCategoryIdAtomMainPage,
13 | })
14 |
--------------------------------------------------------------------------------
/src/store/myRecordAtom.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil'
2 |
3 | export const searchedKeyword = atom<{
4 | keyword: string
5 | }>({
6 | key: 'searchedKeyword',
7 | default: {
8 | keyword: '',
9 | },
10 | })
11 |
--------------------------------------------------------------------------------
/src/types/auth.ts:
--------------------------------------------------------------------------------
1 | export interface IAuth {
2 | type: string
3 | token: string
4 | }
5 |
6 | export interface ISignUp {
7 | type: string
8 | tempId: string
9 | nickname: string
10 | }
11 |
12 | export interface User {
13 | nickname: string
14 | }
15 |
--------------------------------------------------------------------------------
/src/types/category.ts:
--------------------------------------------------------------------------------
1 | export type parentCategoryID = 1 | 2
2 |
--------------------------------------------------------------------------------
/src/types/myRecord.ts:
--------------------------------------------------------------------------------
1 | import { RecordCategory } from './recordData'
2 | import { PaginationRequest } from './request'
3 | import { PaginationResponse } from './response'
4 |
5 | // 추억 레코드
6 | interface IRecordMemoryComment {
7 | commentId: number
8 | content: string
9 | }
10 |
11 | export interface IMemoryRecord extends RecordCategory {
12 | memoryRecordComments: IRecordMemoryComment[]
13 | }
14 |
15 | export interface IMemoryRecordList extends PaginationResponse {
16 | memoryRecordList: IMemoryRecord[]
17 | }
18 |
19 | // 마이 레코드
20 | export interface IMyRecord {
21 | recordId: number
22 | title: string
23 | categoryName: string
24 | commentCount: number
25 | iconName: string
26 | colorName: string
27 | createdAt: string
28 | }
29 |
30 | export interface IMyRecordByKeywordList extends PaginationResponse {
31 | recordBySearchDtos: IMyRecord[]
32 | }
33 |
34 | export interface IMyRecordRequestParam extends PaginationRequest {
35 | date: string
36 | }
37 |
38 | export interface IMyRecordByKeywordRequestParam extends PaginationRequest {
39 | keyword: string
40 | }
41 |
42 | export interface IRecordWithMonthYear {
43 | writtenRecordDayDto: number[]
44 | }
45 |
--------------------------------------------------------------------------------
/src/types/recordData.ts:
--------------------------------------------------------------------------------
1 | import { IMemoryRecord } from './myRecord'
2 |
3 | export interface IRecordDataType {
4 | recordId: number
5 | categoryId: number
6 | categoryName: string
7 | title: string
8 | content: string
9 | writer: string
10 | colorName: string
11 | iconName: string
12 | createdAt: string
13 | imageUrls: string[]
14 | }
15 |
16 | export interface RecordCategory {
17 | recordId: number
18 | title: string
19 | iconName: string
20 | colorName: string
21 | }
22 |
23 | export interface CategoryCard {
24 | colorName: string
25 | commentCount: number
26 | iconName: string
27 | recordId: number
28 | title: string
29 | }
30 |
31 | export interface IRandomRecordData
32 | extends Omit {
33 | commentCount: number
34 | }
35 |
36 | export interface IMixRecordData {
37 | recordId: number
38 | colorName: string
39 | iconName: string
40 | commentId: number
41 | commentContent: string
42 | }
43 |
44 | export interface IRankingRecordData {
45 | colorName: string
46 | iconName: string
47 | numOfComment: number
48 | recordId: number
49 | title: string
50 | writer: string
51 | }
52 |
--------------------------------------------------------------------------------
/src/types/replyData.ts:
--------------------------------------------------------------------------------
1 | export interface CommentData {
2 | commentId: number
3 | content: string
4 | createdAt: string
5 | imageUrl: string | null
6 | modifiedAt: string
7 | numOfSubComment: number
8 | writer?: string
9 | recordwriter?: string
10 | recordId?: string | undefined
11 | }
12 |
13 | export interface CommentRequestDtoType {
14 | page: number
15 | parentId: number | null
16 | recordId: number
17 | size: number
18 | }
19 |
20 | export interface MyCommentType {
21 | commentCreatedAt: string
22 | commentId: number
23 | content: string
24 | nickname: string
25 | }
26 |
27 | export interface MyRepliesType {
28 | categoryName: string
29 | colorName: string
30 | commentsCount: number
31 | iconName: string
32 | myCommentDtos: []
33 | recordCreatedAt: string
34 | recordId: number
35 | title: string
36 | recordWriterNickname: string
37 | }
38 |
39 | export interface DeleteReplyType {
40 | commentId: number
41 | recordId: string | undefined
42 | }
43 |
--------------------------------------------------------------------------------
/src/types/request.ts:
--------------------------------------------------------------------------------
1 | export interface PaginationRequest {
2 | page: number
3 | size: number
4 | }
5 |
--------------------------------------------------------------------------------
/src/types/response.ts:
--------------------------------------------------------------------------------
1 | export interface PaginationResponse {
2 | totalCount: number
3 | totalPage: number
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/fileSize.ts:
--------------------------------------------------------------------------------
1 | const MAX_FILE_SIZE = 5
2 |
3 | export const getByteSize = (size: number) => {
4 | return size / 1000 / 1000
5 | }
6 |
7 | export const checkFileSize = (
8 | e: React.ChangeEvent,
9 | callbackFn: () => void
10 | ) => {
11 | let isOver5MB = false
12 | const files = e.target.files as FileList
13 | const getSize = () => {
14 | for (let i = 0; i < files.length; i++) {
15 | const convertedSize = getByteSize(files[i].size)
16 | if (convertedSize > MAX_FILE_SIZE) {
17 | callbackFn()
18 | isOver5MB = true
19 | break
20 | }
21 | }
22 | }
23 | ;[].forEach.call(e.target.files, getSize)
24 | return isOver5MB
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/getCurrentTime.ts:
--------------------------------------------------------------------------------
1 | export class GetCurrentTime {
2 | date = new Date()
3 | getZero(dates: number) {
4 | return String(dates).padStart(2, '0')
5 | }
6 | getDates() {
7 | const currentMonth = this.date.getMonth() + 1
8 | const currentDay = this.date.getDate()
9 | return `${this.date.getFullYear()}-${this.getZero(
10 | currentMonth
11 | )}-${this.getZero(currentDay)}`
12 | }
13 | getHours() {
14 | const currentHours = this.date.getHours()
15 | return this.getZero(currentHours)
16 | }
17 | getMinutes() {
18 | const currentMinute = this.date.getMinutes()
19 | return this.getZero(currentMinute)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/getFormattedDate.ts:
--------------------------------------------------------------------------------
1 | export const DATE_JOIN_POINT = 'point'
2 |
3 | export const getFormattedDate = (date: Date, type: string) => {
4 | const year = date.getFullYear()
5 | const month = (date.getMonth() + 1).toString().padStart(2, '0')
6 | const day = date.getDate().toString().padStart(2, '0')
7 |
8 | if (type === DATE_JOIN_POINT) {
9 | return `${year}.${month}.${day}`
10 | }
11 |
12 | return `${year}-${month}-${day}`
13 | }
14 |
15 | export const getFormattedDateByString = (date: string, type: string) => {
16 | if (type === DATE_JOIN_POINT) {
17 | return date.split('T')[0].replaceAll('-', '.')
18 | }
19 | return date.split('T')[0]
20 | }
21 |
22 | export const getFormattedDateWithMonthYear = (year: number, month: number) => {
23 | return `${year}-${month.toString().padStart(2, '0')}`
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/getIsValidateNickname.ts:
--------------------------------------------------------------------------------
1 | import { NICKNAME_MIN_LENGTH } from '@assets/constant/constant'
2 | import { Dispatch, SetStateAction } from 'react'
3 |
4 | export const getIsValidateNickname = (
5 | nickname: string,
6 | setErrorMessage: Dispatch>
7 | ) => {
8 | const spacePattern = /\s/g
9 | const consonantAndVowelPattern = /[ㄱ-ㅎㅏ-ㅣ]/
10 | const specialPattern = /[`~!@#$%^&*()_|+\-=?;:'",.<>\\{}[\]\\/₩]/gim
11 | const emojiPattern =
12 | /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g
13 |
14 | if (nickname.length < NICKNAME_MIN_LENGTH) {
15 | setErrorMessage(`${NICKNAME_MIN_LENGTH}글자 이상 입력해주세요.`)
16 | return false
17 | }
18 |
19 | if (nickname.match(spacePattern)) {
20 | setErrorMessage('공백은 사용할 수 없어요.')
21 | return false
22 | }
23 |
24 | if (nickname.match(specialPattern)) {
25 | setErrorMessage('특수문자는 사용할 수 없어요.')
26 | return false
27 | }
28 |
29 | if (nickname.match(consonantAndVowelPattern)) {
30 | setErrorMessage('자음이나 모음만은 사용할 수 없어요.')
31 | return false
32 | }
33 |
34 | if (nickname.match(emojiPattern)) {
35 | setErrorMessage('이모지는 사용할 수 없어요.')
36 | return false
37 | }
38 |
39 | return true
40 | }
41 |
--------------------------------------------------------------------------------
/src/utils/getTimeGap.ts:
--------------------------------------------------------------------------------
1 | export const getTimeGap = (time: number): number => {
2 | return Math.floor((new Date().getTime() - time) / 1000)
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/localStorage.ts:
--------------------------------------------------------------------------------
1 | export const LocalStorage = {
2 | get(key: string): string | null {
3 | return localStorage.getItem(key)
4 | },
5 |
6 | set(key: string, value: string) {
7 | localStorage.setItem(key, value)
8 | },
9 |
10 | remove(key: string) {
11 | localStorage.removeItem(key)
12 | },
13 |
14 | clear() {
15 | localStorage.clear()
16 | },
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/sessionStorage.ts:
--------------------------------------------------------------------------------
1 | export const SessionStorage = {
2 | get(key: string): string | null {
3 | return sessionStorage.getItem(key)
4 | },
5 |
6 | set(key: string, value: string) {
7 | sessionStorage.setItem(key, value)
8 | },
9 |
10 | remove(key: string) {
11 | sessionStorage.removeItem(key)
12 | },
13 |
14 | clear() {
15 | sessionStorage.clear()
16 | },
17 | }
18 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
3 | corePlugins: {
4 | preflight: false,
5 | },
6 | plugins: [],
7 | theme: {
8 | extend: {
9 | colors: {
10 | 'primary-1': '#6026DA',
11 | 'primary-2': '#703CDE',
12 | 'primary-3': '#8052E1',
13 | 'primary-4': '#9067E5',
14 | 'primary-5': '#9F7DE9',
15 | 'primary-6': '#AF93EC',
16 | 'primary-7': '#BFA8F0',
17 | 'primary-8': '#CFBEF4',
18 | 'primary-9': '#DFD4F8',
19 | 'primary-10': '#EFE9FB',
20 |
21 | 'sub-1': '#F33D63',
22 | 'sub-2': '#F45073',
23 | 'sub-3': '#F56482',
24 | 'sub-4': '#F77792',
25 | 'sub-5': '#F88BA1',
26 | 'sub-6': '#F99EB1',
27 | 'sub-7': '#FAB1C1',
28 | 'sub-8': '#FCC5D0',
29 | 'sub-9': '#FDD8E0',
30 | 'sub-10': '#FEECEF',
31 |
32 | 'grey-1': '#FFFFFF',
33 | 'grey-2': '#F1F1F1',
34 | 'grey-3': '#E0E0E0',
35 | 'grey-4': '#CECECE',
36 | 'grey-5': '#B8B8B8',
37 | 'grey-6': '#A0A0A0',
38 | 'grey-7': '#7D7D7D',
39 | 'grey-8': '#656565',
40 | 'grey-9': '#3E3E3E',
41 | 'grey-10': '#121212',
42 |
43 | 'icon-purple': '#9067E5',
44 | 'icon-yellow': '#F3D06C',
45 | 'icon-pink': '#D78A86',
46 | 'icon-blue': '#6F99F2',
47 | 'icon-green': '#78BCB7',
48 |
49 | danger: '#DA2626',
50 | inactive: '#D0D0D0',
51 |
52 | report: '#F83636',
53 |
54 | kakao: '#FEE500',
55 | },
56 | spacing: {
57 | 85: '335px',
58 | },
59 | screens: {
60 | web: { min: '450px' },
61 | basic: { min: '375px', max: '450px' },
62 | small: { max: '375px' },
63 | },
64 | keyframes: {
65 | popUp: {
66 | '0%': { transform: 'translateY(200px)' },
67 | '100%': { transform: 'translateY(0px)' },
68 | },
69 | },
70 | },
71 | },
72 | }
73 |
--------------------------------------------------------------------------------
/tsconfig.extend.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src",
4 | "paths": {
5 | "@app": ["./App.tsx"],
6 | "@store/*": ["./store/*"],
7 | "@pages/*": ["./pages/*"],
8 | "@components/*": ["./components/*"],
9 | "@routes/*": ["./routes/*"],
10 | "@assets/*": ["./assets/*"],
11 | "@utils/*": ["./utils/*"],
12 | "@apis/*": ["./apis/*"],
13 | "@hooks/*": ["./hooks/*"],
14 | "@react-query/*": ["./react-query/*"]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": false,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ],
26 | "extends": "./tsconfig.extend.json",
27 | }
28 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 |
3 | module.exports = {
4 | rules: [
5 | {
6 | test: /.jsx?$/,
7 | include: [path.resolve(__dirname, 'src')],
8 | exclude: [path.resolve(__dirname, 'node_modules')],
9 | loader: 'babel-loader',
10 | },
11 | {
12 | test: /.css?$/,
13 | exclude: [],
14 | //로더는 오른쪽부터 읽어들이므로 postcss-loader를 맨 오른쪽에 넣어준다.
15 | use: ['style-loader', 'css-loader', 'postcss-loader'],
16 | },
17 | ],
18 | }
19 |
--------------------------------------------------------------------------------