diff --git a/.env b/.env new file mode 100644 index 0000000..515caf7 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +REACT_APP_VERSION = 1.1.0 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0a72520 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/README.md b/README.md index 6ab8e98..843890a 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,17 @@ -# 서론 - -안녕하세요 🙌🏻 18기 프론트 운영진 김문기입니다. 이번 미션에서는 드디어 투두리스트에서 벗어나 새로운 프로젝트인 **messenger** 만들기를 진행합니다. - -이번주는 특별히 **디자이너와의 협업**으로 진행되는 미션입니다. 디자이너분께서 열심히 리디자인 한 메신저 프로젝트를 여러분들께서 구현해주시면 됩니다. - -동시에, 이번주부터는 새로 **TypeScript**를 적용해보려고 합니다. - -프로젝트의 규모가 커지게 될 수록, 컴포넌트가 가지는 props의 종류 또한 다양해지게 됩니다. 무지성 코딩을 하다보면 가끔 이 props의 구성과 이름, 어떤 타입이 들어가야 하는지 헷갈리기 마련이죠. 보통 그럴 때 다시 컴포넌트 정의 부분으로 돌아가 props의 구성을 보고 오곤 합니다. - -하지만 이럴 때, typescript를 적용한다면 컴포넌트의 구성과 그 이름, 심지어 타입까지 한번에 자동완성으로 편리하게 관리할 수 있고, 생산성이 증대되겠죠. - -또한, **React Hooks**에 조금 더 익숙해지는 것을 목표로 합니다. 여러 Hook들이 있지만 그 중에서도 `useState`, `useEffect`, `useRef`를 중점적으로 사용해 보는 미션인데요, React를 사용하면서 굉장히 자주 쓰이는 Hook들이기 때문에 이 부분을 집중적으로 공부해 보아요! - -그럼 이번 미션도 파이팅입니다!!🎉 - -# 미션 - -## Key Questions - -- JavaScript를 사용할때에 비해 TypeScript를 사용할 때의 장점은 무엇인가요? -- 디자이너로부터 전달받은 피그마 링크 혹은, 피그마 캡처본 -- 컴포넌트를 분리한 기준은 무엇인가요? -- 디자인 시스템을 적용하면서 느낀 점은 무엇인가요? -- 디자이너와 소통하며 느낀점은 무엇인가요? - -## 미션 목표 - -- TypeScript를 사용해봅시다. -- useState로 컴포넌트의 상태를 관리합니다. -- useEffect와 useRef의 사용법을 이해합니다. -- styled-components를 통한 CSS-in-JS 및 CSS Preprocessor의 사용법에 익숙해집니다. - -## 기한 - -2023년 9월 29일 금요일 - -## 필수 구현 기능 - -- 피그마를 보고 [결과화면](https://3th-fb-messenger.netlify.app)과 같이 구현합니다. -- 디자인 시스템을 구축합니다. -- 채팅방 상단의 프로필을 클릭하면 사용자를 변경할 수 있습니다. -- 메세지를 보내면 채팅방 하단으로 스크롤을 이동시킵니다. -- 메세지에 유저 정보(프로필 사진, 이름)를 표시합니다. -- user와 message 데이터를 json 파일에 저장합니다. -- UI는 **반응형을 제외**하고 피그마파일을 따라서 진행합니다. - -### 추가 구현 기능 - -- 더블 클릭 하면 감정표현을 추가합니다. -- 그 외 추가하고 싶은 기능이 있다면 마음껏 추가해 주세요! - -참고로 이번 과제는 다음주까지 이어지는 과제이므로 **확장성**을 충분히 고려해 주세요. 참고로 **4주차 과제에서는 유저 및 기능 추가와 Routing을** 진행합니다. 이를 위해 [recoil](https://recoiljs.org/ko/)이나 [redux](https://ko.redux.js.org/introduction/getting-started/)를 이용한 상태관리를 미리 해보시는 것을 추천합니다!! 모두 공식문서 많이 읽어보시고 자신만의 상태관리 조합도 찾아보면 재밌을 거에요 XD - -## 링크 및 참고자료 - -- [React docs - Hook](https://ko.reactjs.org/docs/hooks-intro.html) -- [React의 Hooks 완벽 정복하기](https://velog.io/@velopert/react-hooks#1-usestate) -- [useEffect 완벽 가이드](https://overreacted.io/ko/a-complete-guide-to-useeffect/) -- [코딩 컨벤션](https://ui.toast.com/fe-guide/ko_CODING-CONVENTION) -- [타입스크립트 핸드북](https://joshua1988.github.io/ts/intro.html) -- [리액트 프로젝트에서 타입스크립트 사용하기 (시리즈)](https://velog.io/@velopert/series/react-with-typescript) -- [디자인 시스템 구축기](https://yozm.wishket.com/magazine/detail/1830/) -- [[영상] : 컴포넌트에 대한 이해](https://www.youtube.com/watch?v=21eiJc90ggo) -- [Styled Component로 디자인 시스템 구축하기](https://zaat.dev/blog/building-a-design-system-in-react-with-styled-components/) -- [ts 절대경로 설정하기](https://tesseractjh.tistory.com/232) +# 배포 + +[오대균의 Todo List](https://ceos-line-plus.vercel.app/) + +# Key Features + +- 모바일과 데스크탑 환경 모두 대응 +- 모바일과 데스크탑의 작동 방식 차별화 +- react router dom을 활용한 라우팅 +- useState(local)와 zustand(global)를 활용한 상태 관리 +- styled components를 활용한 스타일링 +- 읽음 기능 +- 메시지 좋아요 기능 +- 홈화면에서 원하는 유저를 더블 클릭하면 main 유저 변경 기능 +- 새로운 대화 시작 기능 +- 홈, 채팅목록, 새로운 채팅 페이지에서 검색 기능 +- 유저 프로필 변경 기능 diff --git a/package-lock.json b/package-lock.json index 82a715f..e54519f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,24 @@ "name": "react-messenger-18th", "version": "0.1.0", "dependencies": { - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", + "browser-image-compression": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", - "web-vitals": "^2.1.4" + "react-spinners": "^0.13.8", + "styled-components": "^6.0.8", + "styled-reset": "^4.5.1", + "zustand": "^4.4.1" + }, + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "^20.6.5", + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", + "@types/react-router-dom": "^5.3.3", + "@types/styled-components": "^5.1.27", + "typescript": "^5.2.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -25,11 +36,6 @@ "node": ">=0.10.0" } }, - "node_modules/@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -53,6 +59,83 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/cli": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.23.0.tgz", + "integrity": "sha512-17E1oSkGk2IwNILM4jtfAvgjt+ohmpfBky8aLerUfYZhiPNg7ca+CRCxZn8QDxwNhV/upsc2VHBCqGFIR+iBfA==", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "commander": "^4.0.1", + "convert-source-map": "^2.0.0", + "fs-readdir-recursive": "^1.1.0", + "glob": "^7.2.0", + "make-dir": "^2.1.0", + "slash": "^2.0.0" + }, + "bin": { + "babel": "bin/babel.js", + "babel-external-helpers": "bin/babel-external-helpers.js" + }, + "engines": { + "node": ">=6.9.0" + }, + "optionalDependencies": { + "@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3", + "chokidar": "^3.4.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/cli/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@babel/cli/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "node_modules/@babel/cli/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/cli/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/cli/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/@babel/cli/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "engines": { + "node": ">=6" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -530,6 +613,20 @@ "@babel/core": "^7.13.0" } }, + "node_modules/@babel/plugin-external-helpers": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-external-helpers/-/plugin-external-helpers-7.22.5.tgz", + "integrity": "sha512-ngnNEWxmykPk82mH4ajZT0qTztr3Je6hrMuKAslZVM8G1YZTENJSYwrIGtt6KOtznug3exmAtF4so/nPqJuA4A==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-proposal-class-properties": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", @@ -596,6 +693,25 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-proposal-optional-chaining": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", @@ -2270,6 +2386,24 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2680,6 +2814,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, "dependencies": { "jest-get-type": "^29.6.3" }, @@ -2691,6 +2826,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -3132,6 +3268,12 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "node_modules/@nicolo-ribaudo/chokidar-2": { + "version": "2.1.8-no-fsevents.3", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", + "integrity": "sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==", + "optional": true + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -3241,6 +3383,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", + "integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -3559,308 +3709,6 @@ "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@testing-library/dom": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", - "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "peer": true, - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "peer": true - }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", - "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=8", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/@testing-library/jest-dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", - "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.5.0", - "@types/react-dom": "^18.0.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@testing-library/react/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/@testing-library/react/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/react/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/@testing-library/react/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/user-event": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3877,11 +3725,6 @@ "node": ">=10.13.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.2.tgz", - "integrity": "sha512-PHKZuMN+K5qgKIWhBodXzQslTo5P+K/6LqeKXS6O/4liIDdZqaX5RXrCK++LAw+y/nptN48YmUMFiQHRSWYwtQ==" - }, "node_modules/@types/babel__core": { "version": "7.20.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", @@ -4006,6 +3849,22 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw==", + "dev": true, + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4049,6 +3908,7 @@ "version": "29.5.5", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz", "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==", + "dev": true, "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -4058,6 +3918,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -4069,6 +3930,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -4084,12 +3946,14 @@ "node_modules/@types/jest/node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true }, "node_modules/@types/jest/node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -4098,6 +3962,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4112,6 +3977,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4127,6 +3993,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4137,12 +4004,14 @@ "node_modules/@types/jest/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@types/jest/node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -4151,6 +4020,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", @@ -4166,6 +4036,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4174,6 +4045,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -4188,6 +4060,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -4196,6 +4069,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", @@ -4210,6 +4084,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", @@ -4229,6 +4104,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -4245,6 +4121,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -4258,6 +4135,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -4268,12 +4146,14 @@ "node_modules/@types/jest/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true }, "node_modules/@types/jest/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4297,9 +4177,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.6.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.4.tgz", - "integrity": "sha512-nU6d9MPY0NBUMiE/nXd2IIoC4OLvsLpwAjheoAeuzgvDZA1Cb10QYg+91AF6zQiKWRN5i1m07x6sMe0niBznoQ==" + "version": "20.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.5.tgz", + "integrity": "sha512-2qGq5LAOTh9izcc0+F+dToFigBWiK1phKPt7rNhOqJSr35y8rlIBjDwGtFSgAI6MGIhjwOVNSQZVdJsZJ2uR1w==" }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -4314,7 +4194,8 @@ "node_modules/@types/prop-types": { "version": "15.7.7", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.7.tgz", - "integrity": "sha512-FbtmBWCcSa2J4zL781Zf1p5YUBXQomPEcep9QZCfRfQgTxz3pJWiDFLebohZ9fFntX5ibzOkSsrJ0TEew8cAog==" + "integrity": "sha512-FbtmBWCcSa2J4zL781Zf1p5YUBXQomPEcep9QZCfRfQgTxz3pJWiDFLebohZ9fFntX5ibzOkSsrJ0TEew8cAog==", + "devOptional": true }, "node_modules/@types/q": { "version": "1.5.6", @@ -4335,6 +4216,7 @@ "version": "18.2.22", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.22.tgz", "integrity": "sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==", + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4345,10 +4227,32 @@ "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", + "dev": true, "dependencies": { "@types/react": "*" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -4365,7 +4269,8 @@ "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "devOptional": true }, "node_modules/@types/semver": { "version": "7.5.2", @@ -4412,14 +4317,22 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, - "node_modules/@types/testing-library__jest-dom": { - "version": "5.14.9", - "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", - "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "node_modules/@types/styled-components": { + "version": "5.1.27", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.27.tgz", + "integrity": "sha512-oY9c1SdztRRF0QDQdwXEenfAjGN4WGUkaMpx5hvdTbYYqw01qoY2GrHi+kAR6SVofynzD6KbGoF5ITP0zh5pvg==", + "dev": true, "dependencies": { - "@types/jest": "*" + "@types/hoist-non-react-statics": "*", + "@types/react": "*", + "csstype": "^3.0.2" } }, + "node_modules/@types/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-n4sx2bqL0mW1tvDf/loQ+aMX7GQD3lc3fkCMC55VFNDu/vBOabO+LTIeXKM14xK0ppk5TUGcWRjiSpIlUpghKw==" + }, "node_modules/@types/trusted-types": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.4.tgz", @@ -5710,6 +5623,14 @@ "node": ">=8" } }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -5826,6 +5747,14 @@ "node": ">= 6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -6261,6 +6190,14 @@ "postcss": "^8.4" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -6442,6 +6379,16 @@ "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "1.0.0-alpha.37", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", @@ -6473,11 +6420,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" - }, "node_modules/cssdb": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.2.tgz", @@ -6683,34 +6625,6 @@ "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" }, - "node_modules/deep-equal": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.2.tgz", - "integrity": "sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.1", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6903,11 +6817,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" - }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -7162,25 +7071,6 @@ "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", @@ -8549,6 +8439,11 @@ "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.4.tgz", "integrity": "sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ==" }, + "node_modules/fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8904,6 +8799,21 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -9244,14 +9154,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9292,21 +9194,6 @@ "node": ">= 10" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -12155,14 +12042,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -12301,14 +12180,6 @@ "node": ">=6" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "engines": { - "node": ">=4" - } - }, "node_modules/mini-css-extract-plugin": { "version": "2.7.6", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz", @@ -12583,21 +12454,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -14686,6 +14542,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz", + "integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==", + "dependencies": { + "@remix-run/router": "1.9.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz", + "integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==", + "dependencies": { + "@remix-run/router": "1.9.0", + "react-router": "6.16.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -14758,6 +14644,15 @@ } } }, + "node_modules/react-spinners": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.13.8.tgz", + "integrity": "sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14801,18 +14696,6 @@ "node": ">=6.0.0" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -15513,6 +15396,11 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -15809,17 +15697,6 @@ "node": ">= 0.8" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -15972,17 +15849,6 @@ "node": ">=6" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -16009,6 +15875,60 @@ "webpack": "^5.0.0" } }, + "node_modules/styled-components": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.0.8.tgz", + "integrity": "sha512-AwI02MTWZwqjzfXgR5QcbmcSn5xVjY4N2TLjSuYnmuBGF3y7GicHz3ysbpUq2EMJP5M8/Nc22vcmF3V3WNZDFA==", + "dependencies": { + "@babel/cli": "^7.21.0", + "@babel/core": "^7.21.0", + "@babel/helper-module-imports": "^7.18.6", + "@babel/plugin-external-helpers": "^7.18.6", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@babel/traverse": "^7.21.2", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/unitless": "^0.8.0", + "@types/stylis": "^4.0.2", + "css-to-react-native": "^3.2.0", + "csstype": "^3.1.2", + "postcss": "^8.4.23", + "shallowequal": "^1.1.0", + "stylis": "^4.3.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "babel-plugin-styled-components": ">= 2", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "babel-plugin-styled-components": { + "optional": true + } + } + }, + "node_modules/styled-reset": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/styled-reset/-/styled-reset-4.5.1.tgz", + "integrity": "sha512-6EvFWZRwaFRFxiPYMwmnzOe33rDkw5r9jIU0eEi49bkt6VSrvjeMp2ZOw/YFbw5SVs81llIY+5fzHtR2/VBZfQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "styled-components": ">=4.0.0 || >=5.0.0 || >=6.0.0" + } + }, "node_modules/stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", @@ -16024,6 +15944,11 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz", + "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==" + }, "node_modules/sucrase": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", @@ -16653,16 +16578,15 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { @@ -16807,6 +16731,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -16847,6 +16779,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==" + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -16916,11 +16853,6 @@ "minimalistic-assert": "^1.0.0" } }, - "node_modules/web-vitals": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", - "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==" - }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -17836,6 +17768,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.1.tgz", + "integrity": "sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 49b3308..8103134 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,15 @@ "version": "0.1.0", "private": true, "dependencies": { - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", + "browser-image-compression": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", - "web-vitals": "^2.1.4" + "react-spinners": "^0.13.8", + "styled-components": "^6.0.8", + "styled-reset": "^4.5.1", + "zustand": "^4.4.1" }, "scripts": { "start": "react-scripts start", @@ -34,5 +36,17 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "^20.6.5", + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", + "@types/react-router-dom": "^5.3.3", + "@types/styled-components": "^5.1.27", + "typescript": "^5.2.2" + }, + "overrides": { + "typescript": "^5.2.2" } } diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index a11777c..0000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/favicon/android-icon-144x144.png b/public/favicon/android-icon-144x144.png new file mode 100644 index 0000000..3cfbed0 Binary files /dev/null and b/public/favicon/android-icon-144x144.png differ diff --git a/public/favicon/android-icon-192x192.png b/public/favicon/android-icon-192x192.png new file mode 100644 index 0000000..58ef396 Binary files /dev/null and b/public/favicon/android-icon-192x192.png differ diff --git a/public/favicon/android-icon-36x36.png b/public/favicon/android-icon-36x36.png new file mode 100644 index 0000000..b1185d5 Binary files /dev/null and b/public/favicon/android-icon-36x36.png differ diff --git a/public/favicon/android-icon-48x48.png b/public/favicon/android-icon-48x48.png new file mode 100644 index 0000000..bc167b1 Binary files /dev/null and b/public/favicon/android-icon-48x48.png differ diff --git a/public/favicon/android-icon-72x72.png b/public/favicon/android-icon-72x72.png new file mode 100644 index 0000000..9d1aefb Binary files /dev/null and b/public/favicon/android-icon-72x72.png differ diff --git a/public/favicon/android-icon-96x96.png b/public/favicon/android-icon-96x96.png new file mode 100644 index 0000000..5319174 Binary files /dev/null and b/public/favicon/android-icon-96x96.png differ diff --git a/public/favicon/apple-icon-114x114.png b/public/favicon/apple-icon-114x114.png new file mode 100644 index 0000000..f0d440c Binary files /dev/null and b/public/favicon/apple-icon-114x114.png differ diff --git a/public/favicon/apple-icon-120x120.png b/public/favicon/apple-icon-120x120.png new file mode 100644 index 0000000..74e6fff Binary files /dev/null and b/public/favicon/apple-icon-120x120.png differ diff --git a/public/favicon/apple-icon-144x144.png b/public/favicon/apple-icon-144x144.png new file mode 100644 index 0000000..3cfbed0 Binary files /dev/null and b/public/favicon/apple-icon-144x144.png differ diff --git a/public/favicon/apple-icon-152x152.png b/public/favicon/apple-icon-152x152.png new file mode 100644 index 0000000..e505921 Binary files /dev/null and b/public/favicon/apple-icon-152x152.png differ diff --git a/public/favicon/apple-icon-180x180.png b/public/favicon/apple-icon-180x180.png new file mode 100644 index 0000000..1c02315 Binary files /dev/null and b/public/favicon/apple-icon-180x180.png differ diff --git a/public/favicon/apple-icon-57x57.png b/public/favicon/apple-icon-57x57.png new file mode 100644 index 0000000..fa5d3da Binary files /dev/null and b/public/favicon/apple-icon-57x57.png differ diff --git a/public/favicon/apple-icon-60x60.png b/public/favicon/apple-icon-60x60.png new file mode 100644 index 0000000..9d87654 Binary files /dev/null and b/public/favicon/apple-icon-60x60.png differ diff --git a/public/favicon/apple-icon-72x72.png b/public/favicon/apple-icon-72x72.png new file mode 100644 index 0000000..9d1aefb Binary files /dev/null and b/public/favicon/apple-icon-72x72.png differ diff --git a/public/favicon/apple-icon-76x76.png b/public/favicon/apple-icon-76x76.png new file mode 100644 index 0000000..1461c3b Binary files /dev/null and b/public/favicon/apple-icon-76x76.png differ diff --git a/public/favicon/apple-icon-precomposed.png b/public/favicon/apple-icon-precomposed.png new file mode 100644 index 0000000..4275361 Binary files /dev/null and b/public/favicon/apple-icon-precomposed.png differ diff --git a/public/favicon/apple-icon.png b/public/favicon/apple-icon.png new file mode 100644 index 0000000..4275361 Binary files /dev/null and b/public/favicon/apple-icon.png differ diff --git a/public/favicon/browserconfig.xml b/public/favicon/browserconfig.xml new file mode 100644 index 0000000..c554148 --- /dev/null +++ b/public/favicon/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff \ No newline at end of file diff --git a/public/favicon/favicon-16x16.png b/public/favicon/favicon-16x16.png new file mode 100644 index 0000000..410fa10 Binary files /dev/null and b/public/favicon/favicon-16x16.png differ diff --git a/public/favicon/favicon-32x32.png b/public/favicon/favicon-32x32.png new file mode 100644 index 0000000..650d0c6 Binary files /dev/null and b/public/favicon/favicon-32x32.png differ diff --git a/public/favicon/favicon-96x96.png b/public/favicon/favicon-96x96.png new file mode 100644 index 0000000..5319174 Binary files /dev/null and b/public/favicon/favicon-96x96.png differ diff --git a/public/favicon/favicon.ico b/public/favicon/favicon.ico new file mode 100644 index 0000000..ec6666d Binary files /dev/null and b/public/favicon/favicon.ico differ diff --git a/public/favicon/manifest.json b/public/favicon/manifest.json new file mode 100644 index 0000000..013d4a6 --- /dev/null +++ b/public/favicon/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "App", + "icons": [ + { + "src": "\/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "\/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "\/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "\/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "\/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "\/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] +} \ No newline at end of file diff --git a/public/favicon/ms-icon-144x144.png b/public/favicon/ms-icon-144x144.png new file mode 100644 index 0000000..3cfbed0 Binary files /dev/null and b/public/favicon/ms-icon-144x144.png differ diff --git a/public/favicon/ms-icon-150x150.png b/public/favicon/ms-icon-150x150.png new file mode 100644 index 0000000..f35ad06 Binary files /dev/null and b/public/favicon/ms-icon-150x150.png differ diff --git a/public/favicon/ms-icon-310x310.png b/public/favicon/ms-icon-310x310.png new file mode 100644 index 0000000..a2aa61e Binary files /dev/null and b/public/favicon/ms-icon-310x310.png differ diff --git a/public/favicon/ms-icon-70x70.png b/public/favicon/ms-icon-70x70.png new file mode 100644 index 0000000..098c572 Binary files /dev/null and b/public/favicon/ms-icon-70x70.png differ diff --git a/public/index.html b/public/index.html index aa069f2..3fdd44d 100644 --- a/public/index.html +++ b/public/index.html @@ -2,19 +2,90 @@ - - - + + + + + + + + + + + + + - + + + - + - React App + CEOS LINE PLUS diff --git a/public/logo192.png b/public/logo192.png deleted file mode 100644 index fc44b0a..0000000 Binary files a/public/logo192.png and /dev/null differ diff --git a/public/logo512.png b/public/logo512.png deleted file mode 100644 index a4e47a6..0000000 Binary files a/public/logo512.png and /dev/null differ diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 080d6c7..0000000 --- a/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "short_name": "React App", - "name": "Create React App Sample", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 74b5e05..0000000 --- a/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.js b/src/App.js deleted file mode 100644 index 3784575..0000000 --- a/src/App.js +++ /dev/null @@ -1,25 +0,0 @@ -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( -
-
- logo -

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

- - Learn React - -
-
- ); -} - -export default App; diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ae6436c --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,62 @@ +import { Route, Routes, useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; +import { useMessageStore } from 'stores/messageStore'; +import { useUserStore } from 'stores/userStore'; +import Home from 'pages/home/Home'; +import Profile from 'pages/profile/Profile'; +import ChatList from 'pages/chat/ChatList'; +import ChatRoom from 'pages/chatRoom/ChatRoom'; +import NewChat from 'pages/newChat/NewChat'; +import Layout from 'pages/common/Layout'; +import { BackgroundColor, ChatRoomBackgroundColor } from 'styles/global.style'; + +function App() { + const messages = useMessageStore((state) => state.messages); + const user = useUserStore((state) => state.user); + const location = useLocation(); + + // localStorage에 저장되어 있는 데이터 버전이 일치하지 않을 때 초기화 + useEffect(() => { + const storedVersion = localStorage.getItem('version'); + if (!storedVersion || storedVersion < process.env.REACT_APP_VERSION!) { + localStorage.clear(); + localStorage.setItem('version', process.env.REACT_APP_VERSION!); + // return; + } + }, []); + + useEffect(() => { + localStorage.setItem('messages', JSON.stringify(messages)); + }, [messages]); + + useEffect(() => { + localStorage.setItem(`user`, JSON.stringify(user)); + localStorage.setItem(`user_${user.id}`, JSON.stringify(user)); + }, [user]); + + useEffect(() => { + // 모바일로 접속시 페이지 최상단 부분 색상 적용 + if (/\/chat\//.test(location.pathname)) + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute('content', ChatRoomBackgroundColor); + else + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute('content', BackgroundColor); + }, [location.pathname]); + + return ( + + }> + } /> + } /> + + } /> + } /> + } /> + + ); +} + +export default App; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 0000000..615b5c2 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,7 @@ +declare module '*.svg' { + export const ReactComponent: React.FC>; + const content: string; + export default content; +} + +declare module '*.woff2'; diff --git a/src/data/chatData.json b/src/data/chatData.json new file mode 100644 index 0000000..e990522 --- /dev/null +++ b/src/data/chatData.json @@ -0,0 +1,94 @@ +{ + "data": [ + { + "id": 0, + "fromUserId": 1, + "toUserId": 2, + "text": "짧은 메시지", + "time": "2023-09-26T01:07:37.672Z", + "isRead": true, + "likeCount": 2 + }, + { + "id": 1, + "fromUserId": 2, + "toUserId": 1, + "text": "되게 긴글인데 주저리주저리\n줄바꿈도 하고 사우나도가고 다했다고 주저리주저리주저리주저리주저리\n\n주절스", + "time": "2023-09-26T02:10:04.672Z", + "isRead": true, + "likeCount": 0 + }, + { + "id": 2, + "fromUserId": 1, + "toUserId": 2, + "text": "날짜바뀌는거보이지ABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEFABCDAEF영어도 대응을한다고 ABCDAEF영어도 대응을한다고ABCDAEF영어도 대응을한다고ABCDAEF영어도 대응을한다고ABCDAEF영어도 대응을한다고", + "time": "2023-09-27T09:00:07.672Z", + "isRead": true, + "likeCount": 0 + }, + { + "id": 3, + "fromUserId": 1, + "toUserId": 2, + "text": "한글자", + "time": "2023-09-27T10:23:07.672Z", + "isRead": true, + "likeCount": 0 + }, + { + "id": 4, + "fromUserId": 1, + "toUserId": 2, + "text": "배고픈 사람 손", + "time": "2023-09-27T10:28:07.672Z", + "isRead": true, + "likeCount": 1 + }, + { + "id": 5, + "fromUserId": 2, + "toUserId": 1, + "text": "손", + "time": "2023-09-27T10:29:07.672Z", + "isRead": true, + "likeCount": 0 + }, + { + "id": 6, + "fromUserId": 1, + "toUserId": 2, + "text": "안녕하세요", + "time": "2023-09-27T10:42:07.672Z", + "isRead": true, + "likeCount": 0 + }, + { + "id": 7, + "fromUserId": 2, + "toUserId": 1, + "text": "넵넵", + "time": "2023-09-27T10:42:07.673Z", + "isRead": true, + "likeCount": 1 + }, + { + "id": 8, + "fromUserId": 1, + "toUserId": 2, + "text": "피자먹고싶어요", + "time": "2023-09-27T10:43:07.672Z", + "isRead": true, + "likeCount": 0 + }, + { + "id": 9, + "fromUserId": 1, + "toUserId": 3, + "text": "세오스야 안녕", + "time": "2023-10-04T03:31:07.672Z", + "isRead": true, + "likeCount": 0 + } + ] +} diff --git a/src/data/userData.json b/src/data/userData.json new file mode 100644 index 0000000..539b076 --- /dev/null +++ b/src/data/userData.json @@ -0,0 +1,84 @@ +{ + "data": [ + { + "id": 1, + "name": "오대균", + "profileImage": null, + "statusMessage": "긴 상태메시지 Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,", + "likedMessages": [0, 4], + "github": "https://github.com/oooppq/", + "behance": null, + "instagram": "https://www.instagram.com/daegyunii/" + }, + { + "id": 2, + "name": "배수연", + "profileImage": null, + "statusMessage": "짧은 상태메시지", + "likedMessages": [0, 7], + "github": null, + "behance": "https://www.behance.net/baesy07017a2a", + "instagram": null + }, + { + "id": 3, + "name": "세오스", + "profileImage": null, + "statusMessage": "안녕하세요 세오스입니다. 저는 세오스입니다.", + "likedMessages": [], + "github": "https://github.com/CEOS-Developers", + "behance": null, + "instagram": "https://www.instagram.com/ceos.sinchon/" + }, + { + "id": 4, + "name": "일론머스크", + "profileImage": null, + "statusMessage": "안녕하세요. 저는 자동차를 사랑하는 남자 일론 머스크입니다.", + "likedMessages": [], + "github": null, + "behance": null, + "instagram": null + }, + { + "id": 5, + "name": "오소균", + "profileImage": null, + "statusMessage": "끼잉", + "likedMessages": [], + "github": null, + "behance": null, + "instagram": null + }, + { + "id": 6, + "name": "2023년", + "profileImage": null, + "statusMessage": "거의 끝나간다..", + "likedMessages": [], + "github": null, + "behance": null, + "instagram": null + }, + { + "id": 7, + "name": "호랑이", + "profileImage": null, + "statusMessage": "지금부터는 엑스트라에요.", + "likedMessages": [], + "github": null, + "behance": null, + "instagram": null + }, + { + "id": 8, + "name": "고양이", + "profileImage": null, + "statusMessage": "지금부터는 엑스트라에요.", + "likedMessages": [], + "github": null, + "behance": null, + "instagram": null + } + ] +} diff --git a/src/index.css b/src/index.css deleted file mode 100644 index ec2585e..0000000 --- a/src/index.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index d563c0f..0000000 --- a/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import App from './App'; -import reportWebVitals from './reportWebVitals'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..f429fe1 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { BrowserRouter } from 'react-router-dom'; +import { GlobalStyle } from 'styles/global.style'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + + + + + + +); diff --git a/src/logo.svg b/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/pages/chat/ChatList.tsx b/src/pages/chat/ChatList.tsx new file mode 100644 index 0000000..eedc069 --- /dev/null +++ b/src/pages/chat/ChatList.tsx @@ -0,0 +1,50 @@ +import ChatListBody from 'pages/chat/ChatListBody'; +import ChatListHeader from 'pages/chat/ChatListHeader'; +import { useEffect, useState } from 'react'; +import { ClipLoader } from 'react-spinners'; +import styled from 'styled-components'; + +const ChatList = () => { + const [query, setQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // 탐색중 효과(그냥 기분만 내봄) + useEffect(() => { + if (query) { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 700); + } + }, [query]); + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + ); +}; + +const ChatListContainer = styled.div` + flex: 1; + overflow: hidden; + width: 100%; + background-color: var(--Background-White); + display: flex; + flex-direction: column; +`; + +export default ChatList; diff --git a/src/pages/chat/ChatListBody.tsx b/src/pages/chat/ChatListBody.tsx new file mode 100644 index 0000000..a53657f --- /dev/null +++ b/src/pages/chat/ChatListBody.tsx @@ -0,0 +1,54 @@ +import ChatListElement from 'pages/chat/ChatListElement'; +import { useMessageStore } from 'stores/messageStore'; +import { useUserStore } from 'stores/userStore'; +import styled from 'styled-components'; +import { getLastMessages } from 'utils'; +import userData from 'data/userData.json'; +import { include } from 'utils/search'; + +interface ChatListBodyProps { + query: string; +} + +const ChatListBody = ({ query }: ChatListBodyProps) => { + const user = useUserStore((state) => state.user); + const messages = useMessageStore((state) => state.messages); + // 각 유저와의 마지막 메시지 + const lastMessages = getLastMessages(user.id, messages); + + return ( + + {lastMessages.map((message) => { + // 해당 대화의 상대방 찾기 + const opponentId = + user.id === message.fromUserId + ? message.toUserId + : message.fromUserId; + const storedOpponent = localStorage.getItem(`user_${opponentId}`); + const opponentUser = storedOpponent + ? JSON.parse(storedOpponent) + : userData.data.find((userToCheck) => userToCheck.id === opponentId)!; + //만약 검색어가 있다면 검색어에 해당하는 채팅방만 display + if (!include(opponentUser.name, query)) return null; + return ( + + ); + })} + + ); +}; + +const ChatListBodyContainer = styled.div` + flex: 1; + overflow-y: auto; +`; +export default ChatListBody; diff --git a/src/pages/chat/ChatListElement.tsx b/src/pages/chat/ChatListElement.tsx new file mode 100644 index 0000000..99d5e27 --- /dev/null +++ b/src/pages/chat/ChatListElement.tsx @@ -0,0 +1,100 @@ +import styled from 'styled-components'; +import { ReactComponent as DefaultProfileIcon } from 'static/images/default-profile-icon.svg'; +import { TChatRoomInfo } from 'types'; +import { useNavigate } from 'react-router-dom'; +import { convertTimeFormatForChatRoom } from 'utils'; + +interface ChatListElementProps { + chatRoomInfo: TChatRoomInfo; +} +const ChatListElement = ({ chatRoomInfo }: ChatListElementProps) => { + const navigate = useNavigate(); + + return ( + { + navigate(`./${chatRoomInfo.id}`); + }} + > + + {chatRoomInfo.profileImage ? ( + profile + ) : ( + + )} + + +
{chatRoomInfo.userName}
+
{chatRoomInfo.message}
+
+ + {convertTimeFormatForChatRoom(chatRoomInfo.time)} + +
+ ); +}; + +const ChatListElementContainer = styled.button` + text-align: start; + width: 100%; + // height: 56px; + padding: 6px 20px 6px 14.4px; + margin-bottom: 8px; + display: flex; + align-items: center; +`; + +const ProfileImageConatiner = styled.div` + width: 44px; + height: 44px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + overflow: hidden; + margin-right: 14.6px; + img, + svg { + width: 44px; + } +`; + +const ChatRoomInfo = styled.div` + // padding-top: 3px; + flex: 1; + .username { + height: 19px; + color: var(--Gray-3); + font-size: 14px; + font-weight: 600; + line-height: 160%; + margin-bottom: 4px; + } + .thumb-message { + text-overflow: ellipsis; + overflow: hidden; + white-space: pre-wrap; + word-break: break-all; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + color: var(--Gray-2); + font-size: 14px; + font-weight: 400; + // line-height: 160%; + } +`; + +const ChatRoomDate = styled.div` + align-self: start; + padding-top: 4px; + margin-right: 4px; + color: var(--Gray-2); + font-size: 12px; + font-weight: 300; + line-height: 120%; + white-space: nowrap; +`; + +export default ChatListElement; diff --git a/src/pages/chat/ChatListHeader.tsx b/src/pages/chat/ChatListHeader.tsx new file mode 100644 index 0000000..0cbb3fa --- /dev/null +++ b/src/pages/chat/ChatListHeader.tsx @@ -0,0 +1,57 @@ +import ButtonWithIcon from 'pages/common/ButtonWithIcon'; +import styled from 'styled-components'; +import { ReactComponent as StartChatIcon } from 'static/images/start-chat-icon.svg'; +import { ReactComponent as MoreIcon } from 'static/images/more-icon.svg'; +import { useNavigate } from 'react-router-dom'; +import SearchBar from 'pages/common/SearchBar'; + +interface ChatListHeaderProps { + query: string; + setQuery: React.Dispatch>; +} + +const ChatListHeader = ({ query, setQuery }: ChatListHeaderProps) => { + const navigate = useNavigate(); + + return ( + + +
Chats
+ } + handleClickButton={() => { + navigate('/new-chat'); + }} + size={32} + /> + } size={32} /> +
+ { + setQuery(e.target.value); + }} + /> +
+ ); +}; + +const ChatListHeaderContainer = styled.div` + height: 113px; + width: 100%; + padding: 26px 12px 20px 12px; +`; + +const ChatListHeaderTop = styled.div` + display: flex; + align-items: center; + margin-bottom: 12px; + .title { + font-size: 20px; + font-weight: 600; + line-height: 160%; + margin-right: auto; + } +`; + +export default ChatListHeader; diff --git a/src/pages/chatRoom/ChatRoom.tsx b/src/pages/chatRoom/ChatRoom.tsx new file mode 100644 index 0000000..98b184f --- /dev/null +++ b/src/pages/chatRoom/ChatRoom.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useMessageStore } from 'stores/messageStore'; +import { useUserStore } from 'stores/userStore'; +import ChatRoomBody from 'pages/chatRoom/ChatRoomBody'; +import ChatRoomFooter from 'pages/chatRoom/ChatRoomFooter'; +import ChatRoomHeader from 'pages/chatRoom/ChatRoomHeader'; +import styled from 'styled-components'; +import { TMessage } from 'types'; + +const ChatRoom = () => { + const { id }: { id?: string } = useParams(); // 채팅의 대상 + const user = useUserStore((state) => state.user); // 채팅앱의 주체 + + const messages = useMessageStore((state) => state.messages); + + const setMessages = useMessageStore((state) => state.setMessages); + const toggleIsRead = useMessageStore((state) => state.toggleIsRead); + + const navigate = useNavigate(); + + const headerRef = useRef(null); + const bodyRef = useRef(null); + + useEffect(() => { + if (user.id === Number(id)) { + navigate('/chat'); + } + }, [id, navigate, user.id]); + + // 유저 페이지 전환될 때 메시지에에 대한 읽음처리하도록 + useEffect(() => { + if (user.id !== Number(id)) { + messages.forEach((message, idx) => { + if ( + message.fromUserId === Number(id) && + message.toUserId === user.id && + !message.isRead // 읽지 않은 메시지가 있으면 읽음표시 하도록 + ) + toggleIsRead(idx); + }); + } + }, [id, messages, toggleIsRead, user.id]); + + return ( + + + + (message.fromUserId === user.id && + message.toUserId === Number(id)) || + (message.fromUserId === Number(id) && message.toUserId === user.id) + )} + bodyRef={bodyRef} + /> + { + const newMessage: TMessage = { + id: messages.length, + toUserId: Number(id), + fromUserId: user.id, + text: message, + time: new Date().toISOString(), + isRead: false, + likeCount: 0, + }; + setMessages([...messages, newMessage]); + }} + /> + + ); +}; + +export default ChatRoom; + +// ############### 디자인 ############### + +const ChatRoomContainer = styled.div` + height: 100%; + background-color: var(--Blue); + display: flex; + flex-direction: column; +`; diff --git a/src/pages/chatRoom/ChatRoomBody.tsx b/src/pages/chatRoom/ChatRoomBody.tsx new file mode 100644 index 0000000..b8b28c8 --- /dev/null +++ b/src/pages/chatRoom/ChatRoomBody.tsx @@ -0,0 +1,112 @@ +import EachMessage from 'pages/chatRoom/EachMessage'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useMessageStore } from 'stores/messageStore'; +import { useUserStore } from 'stores/userStore'; +import styled from 'styled-components'; +import { ChatRoomBackgroundColor } from 'styles/global.style'; +import { TMessage } from 'types'; +import { checkIsNextDay } from 'utils'; +import userData from 'data/userData.json'; + +interface ChatRoomBodyProps { + // dummy에 타입 적용 + messages: TMessage[]; + bodyRef: React.RefObject; +} + +const ChatRoomBody = ({ messages, bodyRef }: ChatRoomBodyProps) => { + const { id }: { id?: string } = useParams(); + + const setMessages = useMessageStore((state) => state.setMessages); + const wholeMessages = useMessageStore((state) => state.messages); + + const user = useUserStore((state) => state.user); + const setUser = useUserStore((state) => state.setUser); + + const opponent = localStorage.getItem(`user_${id}`) + ? JSON.parse(localStorage.getItem(`user_${id}`)!) + : userData.data.find((e) => e.id === Number(id)); + + let before = ''; + + // 더블클리 이벤트 헨들러 + const handleDoubleClickMessage = (messageId: number) => { + // const newMessages = [...messages]; + const newMessages = [...wholeMessages]; + // 더블 클릭된 메시지의 index 찾기 + const clickedIdx = newMessages.findIndex( + (message) => message.id === messageId + ); + try { + // 혹시 localStorage에 저장된 data가 변경되는 상황을 대비 + if (user.likedMessages.includes(messageId)) { + // 현재 유저가 이미 좋아한 메시지라면 좋아요 취소 + newMessages[clickedIdx] = { + ...newMessages[clickedIdx], + likeCount: newMessages[clickedIdx].likeCount - 1, + }; + setMessages(newMessages); + setUser({ + ...user, + likedMessages: user.likedMessages.filter((id) => id !== messageId), + }); + } else { + // 이미 좋아요한 메시지가 아니라면 좋아요 + newMessages[clickedIdx] = { + ...newMessages[clickedIdx], + likeCount: newMessages[clickedIdx].likeCount + 1, + }; + setMessages(newMessages); + setUser({ ...user, likedMessages: [...user.likedMessages, messageId] }); + } + } catch { + localStorage.clear(); + window.location.reload(); + } + }; + + // 채팅방 스크롤 아래로 + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.scrollTop = bodyRef.current?.scrollHeight; + } + }, [bodyRef, messages.length]); + + return ( + + {messages.map((message) => { + const isNextDay = checkIsNextDay(before, message.time); + before = message.time; + return ( + + ); + })} + + ); +}; + +export default ChatRoomBody; + +// ############### 디자인 ############### + +const ChatRoomBodyContainer = styled.div` + background-color: ${ChatRoomBackgroundColor}; + padding: 0 8px 56px 8px; + flex-grow: 1; + max-height: 100%; + overflow-y: auto; +`; diff --git a/src/pages/chatRoom/ChatRoomFooter.tsx b/src/pages/chatRoom/ChatRoomFooter.tsx new file mode 100644 index 0000000..727fbe2 --- /dev/null +++ b/src/pages/chatRoom/ChatRoomFooter.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import ButtonWithIcon from 'pages/common/ButtonWithIcon'; +import { ReactComponent as PlusIcon } from 'static/images/plus-icon.svg'; +import { ReactComponent as CameraIcon } from 'static/images/camera-icon.svg'; +import { ReactComponent as PicIcon } from 'static/images/pic-icon.svg'; +import { ReactComponent as MicIcon } from 'static/images/mic-icon.svg'; +import { ReactComponent as SendIcon } from 'static/images/send-icon.svg'; +import { ReactComponent as BackIcon } from 'static/images/back-arrow-icon.svg'; + +interface ChatRoomFooterProps { + headerRef: React.RefObject; + bodyRef: React.RefObject; + sendMessage: (message: string) => void; +} + +const ChatRoomFooter = ({ + headerRef, + bodyRef, + sendMessage, +}: ChatRoomFooterProps) => { + const inputRef = useRef(null); + const hiddenInputRef = useRef(null); + const [content, setContent] = useState(''); + const [isMenuSpread, setIsMenuSpread] = useState(true); + const [isInputFocused, setIsInputFocused] = useState(false); + + // textarea 높이 동적 조절 + const handleChangeContent = (e: React.ChangeEvent) => { + setContent(e.target.value); + e.target.style.height = '36px'; + e.target.style.height = e.target.scrollHeight + 'px'; + }; + + // footer 부분 이외 클릭시 textarea focus out + const handleOnClickOutOfFooter = (e: React.MouseEvent | MouseEvent) => { + if ( + headerRef.current?.contains(e.target as Node) || + bodyRef.current?.contains(e.target as Node) + ) { + setIsInputFocused(false); + setIsMenuSpread(true); + } + }; + + // submit handler + const handleSubmitMessage = () => { + if (content.trim()) { + sendMessage(content); + setContent(''); + // focus 상태에서 전송을 누르면, 계속 focus 유지되도록 + if (isInputFocused) { + // hiddenInput에 focus를 옮기고, 다시 input으로 옮기는 방식을 사용하여 + // ios 환경에서 한글(받침없는 글자) 입력시 buffer가 남아있는 문제를 해결했음 + hiddenInputRef.current?.focus(); + inputRef.current?.focus(); + } + if (inputRef.current) inputRef.current.style.height = '36px'; + } + }; + + // 전체 화면에 클릭이벤트 적용 + useEffect(() => { + document.addEventListener('click', handleOnClickOutOfFooter); + return () => + document.removeEventListener('click', handleOnClickOutOfFooter); + }); + + return ( + + + : } + handleClickButton={() => { + setIsMenuSpread(true); + }} + size={28} + /> + } size={28} /> + } size={28} /> + + + { + setIsMenuSpread(false); + setIsInputFocused(true); + }} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return; //key 조합 감지 + // 모바일 환경이 아닐 때에는 enter로 전송, shift + enter로 줄바꿈 + if (!/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) { + if (e.key === 'Enter' && e.shiftKey) return; + else if (e.key === 'Enter') { + handleSubmitMessage(); + e.preventDefault(); + } + } + }} + value={content} + /> + + : } + handleClickButton={handleSubmitMessage} + size={28} + /> + + ); +}; + +export default ChatRoomFooter; + +// ############### 디자인 ############### + +const ChatRoomFooterContainer = styled.div` + position: fixed; + bottom: 0; + display: flex; + align-items: end; + background-color: white; + width: 100%; + padding: 6px 8px 14px 11px; +`; + +const LeftSideButtonsOuter = styled.div<{ $isMenuSpread: boolean }>` + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 200ms; + width: ${(props) => (props.$isMenuSpread ? '108px' : '36px')}; + display: flex; + overflow: hidden; +`; + +const LeftSideButton = styled(ButtonWithIcon)` + margin-bottom: 8px; + margin-right: 8px; +`; + +const RightSideButton = styled(ButtonWithIcon)` + margin: 0 0 8px 8px; +`; + +const ChatInput = styled.textarea.attrs({ + rows: 1, +})` + max-height: 86px; + border: none; + border-radius: 16px; + background: #f5f5f5; + padding: 9px 17px; + flex: 1; + resize: none; + font-size: 14px; + font-family: 'Pretendard Variable'; +`; + +const HiddenInput = styled.input` + position: absolute; + border: 0; + background-color: transparent; + pointer-events: none; + width: 0; + height: 0; +`; diff --git a/src/pages/chatRoom/ChatRoomHeader.tsx b/src/pages/chatRoom/ChatRoomHeader.tsx new file mode 100644 index 0000000..5a690e7 --- /dev/null +++ b/src/pages/chatRoom/ChatRoomHeader.tsx @@ -0,0 +1,54 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import ButtonWithIcon from 'pages/common/ButtonWithIcon'; +import { ReactComponent as BackIcon } from 'static/images/back-arrow-icon.svg'; +import styled from 'styled-components'; +import userData from 'data/userData.json'; +import { TUser } from 'types'; + +interface ChatRoomHeaderProps { + headerRef: React.RefObject; +} + +const typedUserData: TUser[] = userData.data; + +const ChatRoomHeader = ({ headerRef }: ChatRoomHeaderProps) => { + const navigate = useNavigate(); + const { id }: { id?: string } = useParams(); + + const storedUser = localStorage.getItem(`user_${id}`); + const roomOwner = storedUser // 해당 방이 누구와의 대화방인지 + ? JSON.parse(storedUser) + : typedUserData.find((user) => user.id === Number(id)); + + return ( + + } + handleClickButton={() => { + navigate('/chat'); + }} + size={28} + /> + {roomOwner!.name} + + ); +}; + +export default ChatRoomHeader; + +// ############### 디자인 ############### + +const ChatRoomHeaderContainer = styled.div` + display: flex; + align-items: center; + height: 53px; + width: 100%; + padding: 8px 0 14px 16px; + background-color: var(--Blue); +`; + +const UserNameDiv = styled.div` + font-size: 18px; + font-weight: 600; + margin-left: 9px; +`; diff --git a/src/pages/chatRoom/EachMessage.tsx b/src/pages/chatRoom/EachMessage.tsx new file mode 100644 index 0000000..e5f2152 --- /dev/null +++ b/src/pages/chatRoom/EachMessage.tsx @@ -0,0 +1,159 @@ +import styled from 'styled-components'; +import { TMessage } from 'types'; +import { ReactComponent as HeartIcon } from 'static/images/heart-icon.svg'; +import { ReactComponent as DefaultProfileIcon } from 'static/images/default-profile-icon.svg'; +import { convertDayDateFormat, convertTimeFormat } from 'utils'; + +interface EachMessageProps { + message: TMessage & { profileImage?: string }; + isOwnMessage: boolean; + isNextDay: boolean; + handleDoubleClickMessage: () => void; +} + +const EachMessage = ({ + message, + isOwnMessage, + isNextDay, + handleDoubleClickMessage, +}: EachMessageProps) => { + // 나와 상대방을 구분하여 display하도록 order를 정한 후, flex를 통해 나열 + const orders = isOwnMessage ? [1, 0] : [0, 1]; + + return ( + + {isNextDay && ( + + {convertDayDateFormat(message.time)} + + )} + +
+ {!isOwnMessage && + (message.profileImage ? ( + profile + ) : ( + + ))} +
+ + + + {message.text} + + {message.likeCount > 0 && ( + + +
{message.likeCount}
+
+ )} +
+ + 0}> + {isOwnMessage && message.isRead &&
Read
} +
{convertTimeFormat(message.time)}
+
+
+
+ ); +}; + +export default EachMessage; + +// ############### 디자인 ############### + +const EachMessageContainer = styled.div` + margin-bottom: 8px; +`; + +const MessageBody = styled.div<{ $isOwnMessage: boolean }>` + display: flex; + justify-content: ${(props) => (props.$isOwnMessage ? 'end' : 'start')}; + .profile-image-outer { + width: 36px; + height: 36px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + overflow: hidden; + margin-right: 11px; + img, + svg { + width: 100%; + object-fit: cover; + } + } +`; + +const TextAndLikeOuter = styled.div<{ $order: number; $isOwnMessage: boolean }>` + order: ${(props) => props.$order}; + display: flex; + flex-direction: column; + align-items: ${(props) => (props.$isOwnMessage ? 'end' : 'start')}; +`; + +const MessageText = styled.div<{ $isOwnMessage: boolean }>` + border-radius: 16px; + background-color: ${(props) => + props.$isOwnMessage ? 'var(--Green)' : 'white'}; + padding: 8px 12px; + font-size: 14px; + line-height: 160%; + max-width: 244px; + word-break: break-all; + white-space: break-spaces; +`; + +const MessageExtraInfo = styled.div<{ $order: number; $isLiked: boolean }>` + order: ${(props) => props.$order}; + margin: auto 10px 0 10px; + margin-bottom: ${(props) => (props.$isLiked ? '31px' : '3px')}; + font-size: 10px; + font-weight: 300; + line-height: 160%; + color: var(--Gray-3); + display: flex; + flex-direction: column; + align-items: end; +`; + +const LikeContainer = styled.div` + width: 40px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 16px; + background-color: var(--gray-30); + margin: 6px 0 2px 0; + svg { + margin-right: 3px; + } + .like-count { + margin-left: 3px; + font-size: 12px; + font-weight: 300; + line-height: 120%; + color: white; + } +`; + +const DayDateContainer = styled.div` + width: fit-content; + margin: 4px 0 12px 0; + margin-left: 50%; + transform: translate(-50%, 0%); + padding: 3px 8px; + + height: 20px; + border-radius: 16px; + background-color: var(--gray-30); + font-size: 12px; + font-weight: 300; + line-height: 120%; + color: white; +`; diff --git a/src/pages/common/ButtonWithIcon.tsx b/src/pages/common/ButtonWithIcon.tsx new file mode 100644 index 0000000..1755595 --- /dev/null +++ b/src/pages/common/ButtonWithIcon.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; + +interface ButtonWithIconProps { + className?: string; + children: ReactNode; + handleClickButton?: (e?: React.MouseEvent | MouseEvent) => void; + size?: number; +} +const ButtonWithIcon = ({ + className, + children, + handleClickButton, + size, +}: ButtonWithIconProps) => { + return ( + + ); +}; + +const Button = styled.button<{ $size: number | undefined }>` + width: ${(props) => props.$size}px; + height: ${(props) => props.$size}px; + display: inline-block; + font-size: 0; + line-height: 0; + display: flex; + svg, + img { + width: ${(props) => props.$size}px; + height: ${(props) => props.$size}px; + } +`; +export default ButtonWithIcon; diff --git a/src/pages/common/Layout.tsx b/src/pages/common/Layout.tsx new file mode 100644 index 0000000..b2e87db --- /dev/null +++ b/src/pages/common/Layout.tsx @@ -0,0 +1,21 @@ +import NavBar from 'pages/common/NavBar'; +import { Outlet } from 'react-router-dom'; +import styled from 'styled-components'; + +const Layout = () => { + return ( + + + + + ); +}; + +const LayOutContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +export default Layout; diff --git a/src/pages/common/NavBar.tsx b/src/pages/common/NavBar.tsx new file mode 100644 index 0000000..3bab3ca --- /dev/null +++ b/src/pages/common/NavBar.tsx @@ -0,0 +1,52 @@ +import styled from 'styled-components'; +import { ReactComponent as HomeOnIcon } from 'static/images/home-on-icon.svg'; +import { ReactComponent as HomeOffIcon } from 'static/images/home-off-icon.svg'; +import { ReactComponent as ChatOnIcon } from 'static/images/chat-on-icon.svg'; +import { ReactComponent as ChatOffIcon } from 'static/images/chat-off-icon.svg'; +import { ReactComponent as CallOffIcon } from 'static/images/call-off-icon.svg'; +import NavBarButton from 'pages/common/NavBarButton'; +import { useLocation, useNavigate } from 'react-router-dom'; + +const NavBar = () => { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + + : } + buttonName="Home" + handleClickButton={() => { + navigate('/'); + }} + /> + : + } + buttonName="Chat" + handleClickButton={() => { + navigate('/chat'); + }} + /> + } + buttonName="Call" + handleClickButton={() => {}} + /> + + ); +}; + +const NavBarContainer = styled.div` + position: fixed; + bottom: 0; + display: flex; + justify-content: space-between; + width: 100%; + height: 80px; + padding: 24px 52px 0 52px; + background-color: var(--Background-White); +`; + +export default NavBar; diff --git a/src/pages/common/NavBarButton.tsx b/src/pages/common/NavBarButton.tsx new file mode 100644 index 0000000..c0e3640 --- /dev/null +++ b/src/pages/common/NavBarButton.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; + +interface NavBarButtonProps { + children: ReactNode; + buttonName: string; + handleClickButton?: (e?: React.MouseEvent | MouseEvent) => void; +} + +const NavBarButton = ({ + children, + buttonName, + handleClickButton, +}: NavBarButtonProps) => { + return ( + + {children} + {buttonName} + + ); +}; + +const NavBarButtonContainer = styled.button` + svg { + width: 24px; + height: 24px; + } + display: flex; + flex-direction: column; + align-items: center; +`; + +const ButtonName = styled.div` + color: black; + font-size: 12px; + font-weight: 300; + line-height: 120%; + font-style: normal; +`; + +export default NavBarButton; diff --git a/src/pages/common/SearchBar.tsx b/src/pages/common/SearchBar.tsx new file mode 100644 index 0000000..72dd0aa --- /dev/null +++ b/src/pages/common/SearchBar.tsx @@ -0,0 +1,49 @@ +import { ReactComponent as SearchIcon } from 'static/images/search-icon.svg'; +import styled from 'styled-components'; + +interface SearchBarProps { + query: string; + handleChange: (e: React.ChangeEvent) => void; + customStyle?: string; +} + +// 홈, 채팅목록, 새로운 채팅 페이지에서의 search bar +const SearchBar = ({ query, handleChange, customStyle }: SearchBarProps) => { + return ( + + + + + ); +}; + +const SearchBarContainer = styled.div<{ $style: string | undefined }>` + ${(props) => props.$style} + height: 32px; + border-radius: 4px; + background: var(--Gray-1); + display: flex; + align-items: center; + padding: 0 12px; +`; + +const SearchBarInput = styled.input` + font-size: 12px; + font-style: normal; + width: 100%; + font-weight: 300; + line-height: 120%; + color: var(--Gray-2); + border: none; + background: transparent; + margin-left: 10px; + + &::placeholder { + color: var(--Gray-2); + } +`; +export default SearchBar; diff --git a/src/pages/home/FriendListElement.tsx b/src/pages/home/FriendListElement.tsx new file mode 100644 index 0000000..a76f9eb --- /dev/null +++ b/src/pages/home/FriendListElement.tsx @@ -0,0 +1,55 @@ +import styled from 'styled-components'; +import { ReactComponent as DefaultProfileIcon } from 'static/images/default-profile-icon.svg'; +import { TUser } from 'types'; + +interface FriendListElementProps { + user: TUser; + handleDoubleClickUser: () => void; +} + +const FriendListElement = ({ + user, + handleDoubleClickUser, +}: FriendListElementProps) => { + return ( + + + {user.profileImage ? ( + profile + ) : ( + + )} + +
{user.name}
+
+ ); +}; + +const FriendListElementContainer = styled.button` + display: flex; + width: 100%; + align-items: center; + padding: 8px 0; + margin-bottom: 8px; + .username { + color: black; + font-size: 14px; + } +`; + +const ProfileImageConatiner = styled.div` + width: 36px; + height: 36px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + overflow: hidden; + margin-right: 14px; + img, + svg { + width: 36px; + } +`; + +export default FriendListElement; diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx new file mode 100644 index 0000000..99e0c7c --- /dev/null +++ b/src/pages/home/Home.tsx @@ -0,0 +1,49 @@ +import HomeBody from 'pages/home/HomeBody'; +import HomeHeader from 'pages/home/HomeHeader'; +import { useEffect, useState } from 'react'; +import { ClipLoader } from 'react-spinners'; +import styled from 'styled-components'; + +const Home = () => { + const [query, setQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // 탐색중 효과(그냥 기분만 내봄) + useEffect(() => { + if (query) { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 700); + } + }, [query]); + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + ); +}; + +const HomeContainer = styled.div` + flex: 1; + overflow-y: auto; + width: 100%; + background-color: var(--Background-White); + // padding: 0 12px; + // position: relative; +`; +export default Home; diff --git a/src/pages/home/HomeBody.tsx b/src/pages/home/HomeBody.tsx new file mode 100644 index 0000000..46a7748 --- /dev/null +++ b/src/pages/home/HomeBody.tsx @@ -0,0 +1,58 @@ +import FriendListElement from 'pages/home/FriendListElement'; +import styled from 'styled-components'; +import { useUserStore } from 'stores/userStore'; +import { getSearchedUsers } from 'utils'; + +interface HomeBodyProps { + query: string; +} + +const HomeBody = ({ query }: HomeBodyProps) => { + const user = useUserStore((state) => state.user); + const setUser = useUserStore((state) => state.setUser); + + const users = getSearchedUsers(user.id, query); + + return ( + +
+
Friends
+
{users.length}
+
+
+ {users.map((e) => ( + { + setUser(e); + }} + /> + ))} +
+
+ ); +}; + +const HomeBodyContainer = styled.div` + margin-top: 17px; + padding: 0 12px 80px 12px; + .title-outer { + display: flex; + margin-bottom: 4px; + .title { + height: 22px; + font-size: 14px; + line-height: 160%; + margin-right: 8px; + } + .friend-number { + height: 22px; + font-size: 14px; + font-weight: 600; + line-height: 160%; /* 22.4px */ + } + } +`; + +export default HomeBody; diff --git a/src/pages/home/HomeHeader.tsx b/src/pages/home/HomeHeader.tsx new file mode 100644 index 0000000..dc4872f --- /dev/null +++ b/src/pages/home/HomeHeader.tsx @@ -0,0 +1,51 @@ +import Services from 'pages/home/Services'; +import UserProfile from 'pages/home/UserProfile'; +import styled from 'styled-components'; +import { useState } from 'react'; +import SearchBar from 'pages/common/SearchBar'; + +interface HomeHeaderProps { + query: string; + setQuery: React.Dispatch>; +} + +const HomeHeader = ({ query, setQuery }: HomeHeaderProps) => { + // status message가 펼쳐졌는지에 대한 flag + const [isStatusMessageSpread, setIsStatusMessageSpread] = + useState(false); + + return ( + + { + setIsStatusMessageSpread(true); + }} + foldStatusMessage={() => { + setIsStatusMessageSpread(false); + }} + /> + { + setQuery(e.target.value); + }} + customStyle="margin: 0 12px;" + /> + + + ); +}; + +const HomeHeaderContainer = styled.div<{ + $isStatusMessageSpread: boolean; +}>` + margin-top: 26px; + margin-top: ${(props) => (props.$isStatusMessageSpread ? '102px' : '26px')}; +`; + +const StyledSearchBar = styled(SearchBar)` + margin: 0 12px; +`; + +export default HomeHeader; diff --git a/src/pages/home/Services.tsx b/src/pages/home/Services.tsx new file mode 100644 index 0000000..53be854 --- /dev/null +++ b/src/pages/home/Services.tsx @@ -0,0 +1,67 @@ +import styled from 'styled-components'; +import { ReactComponent as StickersIcon } from 'static/images/stickers-icon.svg'; +import { ReactComponent as PencilIcon } from 'static/images/pencil-icon.svg'; +import { ReactComponent as OfficialIcon } from 'static/images/official-icon.svg'; +import { ReactComponent as PointsIcon } from 'static/images/line-point-icon.svg'; + +const Services = () => { + return ( + +
Services
+
+ + +
Stickers
+
+ + +
Themes
+
+ + +
+ Official +
+ Accounts +
+
+ + +
+ LINE +
+ Points +
+
+
+
+ ); +}; + +const ServicesContainer = styled.div` + margin-top: 20px; + padding: 0 12px; + .title { + font-size: 14px; + line-height: 160%; /* 22.4px */ + } + .btns { + margin-top: 12px; + display: flex; + justify-content: space-evenly; + align-items: start; + } +`; + +const ServiceButton = styled.button` + .button-name { + height: 36px; + text-align: center; + font-size: 12px; + font-weight: 300; + color: var(--Gray-3); + line-height: 120%; + } +`; + +export default Services; diff --git a/src/pages/home/UserProfile.tsx b/src/pages/home/UserProfile.tsx new file mode 100644 index 0000000..81e62f0 --- /dev/null +++ b/src/pages/home/UserProfile.tsx @@ -0,0 +1,141 @@ +import styled from 'styled-components'; +import { ReactComponent as DefaultProfileIcon } from 'static/images/default-profile-icon.svg'; +import { ReactComponent as MoreOnIcon } from 'static/images/more-on-icon.svg'; +import { ReactComponent as MoreOffIcon } from 'static/images/more-off-icon.svg'; +import ButtonWithIcon from 'pages/common/ButtonWithIcon'; +import { useUserStore } from 'stores/userStore'; +import { useNavigate } from 'react-router-dom'; + +interface UserProfileProps { + isStatusMessageSpread: boolean; + spreadStatusMessage: () => void; + foldStatusMessage: () => void; +} +const UserProfile = ({ + isStatusMessageSpread, + spreadStatusMessage, + foldStatusMessage, +}: UserProfileProps) => { + const user = useUserStore((state) => state.user); + const navigate = useNavigate(); + + return ( + { + navigate('/profile'); + }} + > +
+
+
{user.name}
+ +
{user.statusMessage}
+ { + foldStatusMessage(); + e.stopPropagation(); + }} + /> + ) : ( + { + spreadStatusMessage(); + e.stopPropagation(); + }} + /> + ) + } + size={28} + /> +
+
+ + {user.profileImage ? ( + profile + ) : ( + + )} + +
+
+ ); +}; + +const UserProfileContainer = styled.div<{ + $isStatusMessageSpread: boolean; +}>` + width: 100%; + ${(props) => + props.$isStatusMessageSpread + ? 'position: fixed; top: 0px; bottom: 0;' + : 'height: 76px;'}; + + background-color: var(--gray-30, rgba(0, 0, 0, 0.3)); + + .profile-body { + padding: 0 12px 12px 12px; + padding-top: ${(props) => (props.$isStatusMessageSpread ? '26px' : '0px')}; + background-color: var(--Background-White); + display: flex; + .profile-info { + margin-top: 4px; + flex: 1; + .username { + height: 32px; + font-size: 20px; + font-weight: 600; + line-height: 160%; + } + } + } +`; + +const StatusMessage = styled.div<{ + $isStatusMessageSpread: boolean; +}>` + display: flex; + .message { + color: var(--Gray-2); + margin-top: 4px; + margin-right: 8px; + font-size: 14px; + line-height: 160%; + flex: 1; + ${(props) => + props.$isStatusMessageSpread + ? '' + : `text-overflow: ellipsis; + overflow: hidden; + white-space: pre-wrap; + word-break: break-all; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical;`}; + } + button { + margin-right: 17px; + } +`; + +const ProfileImageConatiner = styled.div` + width: 44px; + height: 44px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + overflow: hidden; + margin-top: 9px; + margin-right: 4px; + img, + svg { + width: 44px; + // height: 44px; + } +`; + +export default UserProfile; diff --git a/src/pages/newChat/NewChat.tsx b/src/pages/newChat/NewChat.tsx new file mode 100644 index 0000000..9dea239 --- /dev/null +++ b/src/pages/newChat/NewChat.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import NewChatHeader from 'pages/newChat/NewChatHeader'; +import NewChatBody from 'pages/newChat/NewChatBody'; +import { ClipLoader } from 'react-spinners'; + +const NewChat = () => { + const [query, setQuery] = useState(''); // 검색어 + const [selected, setSelected] = useState(null); // 선택된 유저 + const [isLoading, setIsLoading] = useState(false); // 로딩 flag + + // 탐색중 효과(그냥 기분만 내봄) + useEffect(() => { + if (query) { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 700); + } + }, [query]); + + return ( + + { + setQuery(e.target.value); + }} + /> + {isLoading ? ( + + ) : ( + + )} + + ); +}; + +const NewChatContainer = styled.div` + height: 100%; + width: 100%; + padding: 20px 12px 0 12px; + background-color: var(--Background-White); +`; + +export default NewChat; diff --git a/src/pages/newChat/NewChatBody.tsx b/src/pages/newChat/NewChatBody.tsx new file mode 100644 index 0000000..78b3556 --- /dev/null +++ b/src/pages/newChat/NewChatBody.tsx @@ -0,0 +1,34 @@ +import { useUserStore } from 'stores/userStore'; +import styled from 'styled-components'; +import NewChatElement from 'pages/newChat/NewChatElement'; +import { getSearchedUsers } from 'utils'; + +interface NewChatBodyProps { + query: string; + selected: number | null; + setSelected: React.Dispatch>; +} +const NewChatBody = ({ query, selected, setSelected }: NewChatBodyProps) => { + const user = useUserStore((state) => state.user); + const users = getSearchedUsers(user.id, query); + + return ( + + {users.map((e) => ( + { + setSelected(e.id); + }} + /> + ))} + + ); +}; + +const NewChatBodyConatiner = styled.div` + margin-top: 20px; +`; +export default NewChatBody; diff --git a/src/pages/newChat/NewChatElement.tsx b/src/pages/newChat/NewChatElement.tsx new file mode 100644 index 0000000..5382a7a --- /dev/null +++ b/src/pages/newChat/NewChatElement.tsx @@ -0,0 +1,65 @@ +import styled from 'styled-components'; +import { TUser } from 'types'; +import { ReactComponent as DefaultProfileIcon } from 'static/images/default-profile-icon.svg'; + +interface NewChatElementProps { + user: TUser; + checked: boolean; + handleChange: () => void; +} + +const NewChatElement = ({ + user, + checked, + handleChange, +}: NewChatElementProps) => { + return ( + + + {user.profileImage ? ( + profile + ) : ( + + )} + +
{user.name}
+ +
+ ); +}; + +const NewChatElementContainer = styled.button` + display: flex; + width: 100%; + align-items: center; + text-align: start; + padding: 8px 0; + margin-bottom: 8px; + .username { + color: black; + font-size: 14px; + flex: 1; + } +`; + +const ProfileImageConatiner = styled.div` + width: 36px; + height: 36px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + overflow: hidden; + margin-right: 14px; + img, + svg { + width: 36px; + } +`; + +export default NewChatElement; diff --git a/src/pages/newChat/NewChatHeader.tsx b/src/pages/newChat/NewChatHeader.tsx new file mode 100644 index 0000000..a658479 --- /dev/null +++ b/src/pages/newChat/NewChatHeader.tsx @@ -0,0 +1,46 @@ +import SearchBar from 'pages/common/SearchBar'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +interface NewChatHeaderProps { + query: string; + selected: number | null; + handleChange: (e: React.ChangeEvent) => void; +} +const NewChatHeader = ({ + query, + selected, + handleChange, +}: NewChatHeaderProps) => { + const navigate = useNavigate(); + + return ( + + + { + // 선택된 유저가 있으면 해당 유저와의 채팅방으로 이동 + if (selected) navigate(`/chat/${selected}`); + else navigate(-1); + }} + > + {selected ? '확인' : '취소'} + + + ); +}; + +const NewChatHeaderContainer = styled.div` + display: flex; +`; + +const CancelButton = styled.button` + margin-left: 10px; + color: var(--Gray-3); + font-size: 14px; +`; +export default NewChatHeader; diff --git a/src/pages/profile/Profile.tsx b/src/pages/profile/Profile.tsx new file mode 100644 index 0000000..93f0e10 --- /dev/null +++ b/src/pages/profile/Profile.tsx @@ -0,0 +1,56 @@ +import UserLink from 'pages/profile/UserLink'; +import UserProfile from 'pages/profile/UserProfile'; +import styled from 'styled-components'; +import { ReactComponent as InstagramIcon } from 'static/images/instagram-icon.svg'; +import { ReactComponent as GithubIcon } from 'static/images/github-icon.svg'; +import { ReactComponent as BehanceIcon } from 'static/images/behance-icon.svg'; +import { useUserStore } from 'stores/userStore'; +import { useState } from 'react'; +import ProfileHeader from 'pages/profile/ProfileHeader'; + +const Profile = () => { + const user = useUserStore((state) => state.user); + // 프로필 변경 모드 flag + const [isProfileChanging, setIsProfileChanging] = useState(false); + + return ( + + { + setIsProfileChanging((state) => !state); + }} + /> + + } + linkName="Instagram" + href={user.instagram} + /> + } + linkName="Github" + href={user.github} + /> + } + linkName="Behance" + href={user.behance} + /> + + ); +}; + +const ProfileContainer = styled.div` + width: 100%; + height: 100%; + padding: 20px 16px; + background-color: var(--Background-White); +`; + +export default Profile; diff --git a/src/pages/profile/ProfileHeader.tsx b/src/pages/profile/ProfileHeader.tsx new file mode 100644 index 0000000..2fcd4d9 --- /dev/null +++ b/src/pages/profile/ProfileHeader.tsx @@ -0,0 +1,52 @@ +import styled from 'styled-components'; +import { ReactComponent as BackIcon } from 'static/images/back-arrow-icon.svg'; +import { ReactComponent as PencilIcon } from 'static/images/pencil-bold-icon.svg'; +import ButtonWithIcon from 'pages/common/ButtonWithIcon'; +import { useNavigate } from 'react-router-dom'; + +interface ProfileHeaderProps { + isProfileChanging: boolean; + handleClickProfileChangeButton: () => void; +} +const ProfileHeader = ({ + isProfileChanging, + handleClickProfileChangeButton, +}: ProfileHeaderProps) => { + const navigate = useNavigate(); + + return ( + + } + handleClickButton={() => { + navigate('/'); + }} + size={28} + /> + {isProfileChanging ? ( + + ) : ( + } + handleClickButton={handleClickProfileChangeButton} + size={28} + /> + )} + + ); +}; + +const ProfileHeaderContainer = styled.div` + display: flex; + justify-content: space-between; + width: 100%; + .okButton { + height: 28px; + font-size: 16px; + color: black; + } +`; + +export default ProfileHeader; diff --git a/src/pages/profile/ProfileImage.tsx b/src/pages/profile/ProfileImage.tsx new file mode 100644 index 0000000..b0394b2 --- /dev/null +++ b/src/pages/profile/ProfileImage.tsx @@ -0,0 +1,98 @@ +import styled from 'styled-components'; +import { ReactComponent as DefaultProfileIcon } from 'static/images/default-profile-icon.svg'; +import { ReactComponent as CameraIcon } from 'static/images/camera-icon.svg'; +import { CompressImage } from 'utils/fileCompression'; +import { memo } from 'react'; + +interface ProfileImageProps { + isProfileChanging: boolean; + newProfileImage: string | null; + setNewProfileImage: React.Dispatch>; +} + +const ProfileImage = ({ + isProfileChanging, + newProfileImage, + setNewProfileImage, +}: ProfileImageProps) => { + return ( + + + {newProfileImage ? ( + profile + ) : ( + + )} + + {isProfileChanging ? ( + + + { + if (e.target.files && e.target.files.length) { + // 압축 진행 + const compressedFile = await CompressImage(e.target.files[0]); + if (compressedFile) { + // file reader로 이미지 encoding + const reader = new FileReader(); + reader.readAsDataURL(compressedFile); + reader.onloadend = () => { + if (reader.result) { + // string 형태로 이미지 저장 + setNewProfileImage(reader.result.toString()); + } + }; + } + } + }} + /> + + ) : null} + + ); +}; + +const ProfileImageOuter = styled.div` + position: relative; +`; + +const ProfileImageConatiner = styled.div` + width: 108px; + height: 108px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + overflow: hidden; + img, + svg { + width: 100%; + object-fit: cover; + } +`; + +const ProfileImageChangeButton = styled.label` + position: absolute; + bottom: 5px; + right: 5px; + background: var(--Gray-1); + border-radius: 50%; + z-index: 100; + border: 1px solid black; + height: 22px; + width: 22px; + display: flex; + justify-content: center; + align-items: center; + svg { + width: 20px; + height: 20px; + } + input { + display: none; + } +`; + +export default memo(ProfileImage); diff --git a/src/pages/profile/StatusMessage.tsx b/src/pages/profile/StatusMessage.tsx new file mode 100644 index 0000000..9fc0319 --- /dev/null +++ b/src/pages/profile/StatusMessage.tsx @@ -0,0 +1,79 @@ +import { memo, useEffect, useRef } from 'react'; +import styled from 'styled-components'; + +interface StatusMessageProps { + isProfileChanging: boolean; + statusMessage: string | null; + newStatusMessage: string | null; + setNewStatusMessage: React.Dispatch>; +} + +const StatusMessage = ({ + isProfileChanging, + statusMessage, + newStatusMessage, + setNewStatusMessage, +}: StatusMessageProps) => { + const statusMessageRef = useRef(null); + + // textarea의 높이 초기 설정 + useEffect(() => { + if (statusMessageRef.current) { + statusMessageRef.current.style.height = + statusMessageRef.current.scrollHeight + 'px'; + } + }, [isProfileChanging]); + + return ( + + {isProfileChanging ? ( + + ) : ( +
{statusMessage}
+ )} +
+ ); +}; + +const StatusMessageOuter = styled.div` + position: relative; + padding: 0 12px 12px 12px; + margin-top: 4px; + width: 100%; + border-bottom: 1px solid var(--Gray-2); + .status-message { + text-align: center; + white-space: pre-wrap; + color: var(--Gray-2); + font-size: 14px; + line-height: 160%; + } + .status-message-textarea { + margin-top: 10px; + text-align: center; + width: 100%; + color: var(--Gray-2); + padding: 12px; + border: none; + resize: none; + border-radius: 4px; + font-size: 14px; + background: var(--Gray-1); + // line-height: 160%; + } +`; + +export default memo(StatusMessage); diff --git a/src/pages/profile/UserLink.tsx b/src/pages/profile/UserLink.tsx new file mode 100644 index 0000000..43d5e23 --- /dev/null +++ b/src/pages/profile/UserLink.tsx @@ -0,0 +1,43 @@ +import { ReactNode } from 'react'; +import { ReactComponent as RightArrowIcon } from 'static/images/right-arrow-icon.svg'; +import styled from 'styled-components'; + +interface UserLinkProps { + children: ReactNode; + linkName: string; + href: string | null; +} +const UserLink = ({ children, linkName, href }: UserLinkProps) => { + return ( + + {children} +
{linkName}
+ +
+ ); +}; + +const UserLinkContainer = styled.a` + display: flex; + align-items: center; + text-decoration: none; + width: 100%; + height: 40px; + padding: 0 28px 0 32px; + margin-bottom: 4px; + svg { + width: 28px; + height: 28px; + margin-right: 9px; + } + .link-name { + flex: 1; + color: black; + font-family: Pretendard Variable; + font-size: 12px; + font-weight: 300; + line-height: 120%; + } +`; + +export default UserLink; diff --git a/src/pages/profile/UserProfile.tsx b/src/pages/profile/UserProfile.tsx new file mode 100644 index 0000000..cc238ac --- /dev/null +++ b/src/pages/profile/UserProfile.tsx @@ -0,0 +1,83 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import styled from 'styled-components'; +import { useUserStore } from 'stores/userStore'; +import { useEffect, useState } from 'react'; +import ProfileImage from 'pages/profile/ProfileImage'; +import Username from 'pages/profile/Username'; +import StatusMessage from 'pages/profile/StatusMessage'; + +interface UserProfileProps { + username: string; + isProfileChanging: boolean; + profileImage: string | null; + statusMessage: string | null; +} + +const UserProfile = ({ + username, + isProfileChanging, + profileImage, + statusMessage, +}: UserProfileProps) => { + const user = useUserStore((state) => state.user); + const setUser = useUserStore((state) => state.setUser); + + const [newName, setNewName] = useState(username); + const [newStatusMessage, setNewStatusMessage] = useState( + statusMessage + ); + const [newProfileImage, setNewProfileImage] = useState( + profileImage + ); + + // 프로필 변경 모드가 바뀔 때 프로필에 변경사항이 있다면 전역 user state 업데이트 + useEffect(() => { + // console.log(username, newName); + if ( + username !== newName || + statusMessage !== newStatusMessage || + profileImage !== newProfileImage + ) { + setUser({ + ...user, + name: newName, + statusMessage: newStatusMessage, + profileImage: newProfileImage, + }); + } + }, [isProfileChanging]); + + return ( + + + + + + ); +}; + +const UserProfileContainer = styled.div` + margin-top: 66px; + margin-bottom: 8px; + width: 100%; + padding: 0 16px; + display: flex; + flex-direction: column; + align-items: center; +`; + +export default UserProfile; diff --git a/src/pages/profile/Username.tsx b/src/pages/profile/Username.tsx new file mode 100644 index 0000000..37703a2 --- /dev/null +++ b/src/pages/profile/Username.tsx @@ -0,0 +1,56 @@ +import { memo } from 'react'; +import styled from 'styled-components'; + +interface UserNameProps { + isProfileChanging: boolean; + username: string; + newName: string; + setNewName: React.Dispatch>; +} + +const UserName = ({ + isProfileChanging, + username, + newName, + setNewName, +}: UserNameProps) => { + return ( + + {isProfileChanging ? ( + { + // 닉네임 10글자 제한 + if (e.target.value.length <= 10) setNewName(e.target.value); + }} + /> + ) : ( +
{username}
+ )} +
+ ); +}; + +const UsernameOuter = styled.div` + display: flex; + position: relative; + margin-top: 20px; + .username { + font-size: 20px; + font-weight: 600; + line-height: 160%; + } + .username-input { + text-align: center; + border: none; + color: var(--Gray-2); + padding: 6px 12px; + border-radius: 4px; + background: var(--Gray-1); + font-size: 14px; + } +`; + +export default memo(UserName); diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js deleted file mode 100644 index 5253d3a..0000000 --- a/src/reportWebVitals.js +++ /dev/null @@ -1,13 +0,0 @@ -const reportWebVitals = onPerfEntry => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/src/setupTests.js b/src/setupTests.js deleted file mode 100644 index 8f2609b..0000000 --- a/src/setupTests.js +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; diff --git a/src/static/fonts/PretendardVariable.woff2 b/src/static/fonts/PretendardVariable.woff2 new file mode 100644 index 0000000..a8d0637 Binary files /dev/null and b/src/static/fonts/PretendardVariable.woff2 differ diff --git a/src/static/images/back-arrow-icon.svg b/src/static/images/back-arrow-icon.svg new file mode 100644 index 0000000..fd9329c --- /dev/null +++ b/src/static/images/back-arrow-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/behance-icon.svg b/src/static/images/behance-icon.svg new file mode 100644 index 0000000..c14d81d --- /dev/null +++ b/src/static/images/behance-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/static/images/call-off-icon.svg b/src/static/images/call-off-icon.svg new file mode 100644 index 0000000..b009faf --- /dev/null +++ b/src/static/images/call-off-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/camera-icon.svg b/src/static/images/camera-icon.svg new file mode 100644 index 0000000..a3dbf52 --- /dev/null +++ b/src/static/images/camera-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/static/images/chat-off-icon.svg b/src/static/images/chat-off-icon.svg new file mode 100644 index 0000000..a7b4502 --- /dev/null +++ b/src/static/images/chat-off-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/static/images/chat-on-icon.svg b/src/static/images/chat-on-icon.svg new file mode 100644 index 0000000..53200c1 --- /dev/null +++ b/src/static/images/chat-on-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/close-icon.svg b/src/static/images/close-icon.svg new file mode 100644 index 0000000..e3639df --- /dev/null +++ b/src/static/images/close-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/default-profile-icon.svg b/src/static/images/default-profile-icon.svg new file mode 100644 index 0000000..64cc7e9 --- /dev/null +++ b/src/static/images/default-profile-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/static/images/github-icon.svg b/src/static/images/github-icon.svg new file mode 100644 index 0000000..37d2a22 --- /dev/null +++ b/src/static/images/github-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/heart-icon.svg b/src/static/images/heart-icon.svg new file mode 100644 index 0000000..dd7fffa --- /dev/null +++ b/src/static/images/heart-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/home-off-icon.svg b/src/static/images/home-off-icon.svg new file mode 100644 index 0000000..5f3dfd3 --- /dev/null +++ b/src/static/images/home-off-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/images/home-on-icon.svg b/src/static/images/home-on-icon.svg new file mode 100644 index 0000000..11503d4 --- /dev/null +++ b/src/static/images/home-on-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/instagram-icon.svg b/src/static/images/instagram-icon.svg new file mode 100644 index 0000000..2702db9 --- /dev/null +++ b/src/static/images/instagram-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/static/images/line-point-icon.svg b/src/static/images/line-point-icon.svg new file mode 100644 index 0000000..4749776 --- /dev/null +++ b/src/static/images/line-point-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/static/images/mic-icon.svg b/src/static/images/mic-icon.svg new file mode 100644 index 0000000..f441b1b --- /dev/null +++ b/src/static/images/mic-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/images/more-icon.svg b/src/static/images/more-icon.svg new file mode 100644 index 0000000..05f7aec --- /dev/null +++ b/src/static/images/more-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/static/images/more-off-icon.svg b/src/static/images/more-off-icon.svg new file mode 100644 index 0000000..fc98d06 --- /dev/null +++ b/src/static/images/more-off-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/images/more-on-icon.svg b/src/static/images/more-on-icon.svg new file mode 100644 index 0000000..c41cb90 --- /dev/null +++ b/src/static/images/more-on-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/static/images/official-icon.svg b/src/static/images/official-icon.svg new file mode 100644 index 0000000..9963bbb --- /dev/null +++ b/src/static/images/official-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/images/pencil-bold-icon.svg b/src/static/images/pencil-bold-icon.svg new file mode 100644 index 0000000..ff2c0d8 --- /dev/null +++ b/src/static/images/pencil-bold-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/pencil-icon.svg b/src/static/images/pencil-icon.svg new file mode 100644 index 0000000..38a14f1 --- /dev/null +++ b/src/static/images/pencil-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/pic-icon.svg b/src/static/images/pic-icon.svg new file mode 100644 index 0000000..9dd1023 --- /dev/null +++ b/src/static/images/pic-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/images/plus-icon.svg b/src/static/images/plus-icon.svg new file mode 100644 index 0000000..2a1ccb2 --- /dev/null +++ b/src/static/images/plus-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/right-arrow-icon.svg b/src/static/images/right-arrow-icon.svg new file mode 100644 index 0000000..a152750 --- /dev/null +++ b/src/static/images/right-arrow-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/search-icon.svg b/src/static/images/search-icon.svg new file mode 100644 index 0000000..851f9af --- /dev/null +++ b/src/static/images/search-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/static/images/send-icon.svg b/src/static/images/send-icon.svg new file mode 100644 index 0000000..6f6dee8 --- /dev/null +++ b/src/static/images/send-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/images/smile-icon.svg b/src/static/images/smile-icon.svg new file mode 100644 index 0000000..c9dde81 --- /dev/null +++ b/src/static/images/smile-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/static/images/start-chat-icon.svg b/src/static/images/start-chat-icon.svg new file mode 100644 index 0000000..6add522 --- /dev/null +++ b/src/static/images/start-chat-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/static/images/stickers-icon.svg b/src/static/images/stickers-icon.svg new file mode 100644 index 0000000..c9dde81 --- /dev/null +++ b/src/static/images/stickers-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/stores/messageStore.ts b/src/stores/messageStore.ts new file mode 100644 index 0000000..6a728a1 --- /dev/null +++ b/src/stores/messageStore.ts @@ -0,0 +1,35 @@ +import { devtools } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import { TMessage } from 'types'; +import chatData from 'data/chatData.json'; +import { create } from 'zustand'; + +interface TMessageStore { + messages: TMessage[]; + setMessages: (messages: TMessage[]) => void; + toggleIsRead: (idx: number) => void; +} + +const storedMessages: string | null = localStorage.getItem('messages'); +const storedVersion = localStorage.getItem('version'); +// localStorage에 data가 있으면 그것을 사용하고, 아니면 dummy 사용 +const initialMessageState: TMessage[] = + storedMessages && + storedVersion && + storedVersion === process.env.REACT_APP_VERSION + ? JSON.parse(storedMessages) + : chatData.data; + +export const useMessageStore = create( + devtools( + // immer을 통해 messages 내부의 객체 수정 시 불필요한 리렌더링 방지 + immer((set) => ({ + messages: initialMessageState, + toggleIsRead: (idx: number) => + set((state) => { + state.messages[idx].isRead = !state.messages[idx].isRead; + }), + setMessages: (newMessages: TMessage[]) => set({ messages: newMessages }), + })) + ) +); diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts new file mode 100644 index 0000000..6087710 --- /dev/null +++ b/src/stores/userStore.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { TUser } from 'types'; +import userData from 'data/userData.json'; + +interface TUserStore { + user: TUser; + setUser: (user: TUser) => void; +} + +const user: string | null = localStorage.getItem('user'); +const storedVersion = localStorage.getItem('version'); +const initialUserState: TUser = + user && storedVersion && storedVersion === process.env.REACT_APP_VERSION + ? JSON.parse(user) + : userData.data[0]; // default로 설정되는 유저는 user_1 + +export const useUserStore = create( + devtools((set) => ({ + user: initialUserState, + setUser: (newUser: TUser) => set({ user: newUser }), + })) +); diff --git a/src/styles/global.style.ts b/src/styles/global.style.ts new file mode 100644 index 0000000..597e41f --- /dev/null +++ b/src/styles/global.style.ts @@ -0,0 +1,62 @@ +import * as styled from 'styled-components'; +import reset from 'styled-reset'; +import PretendardVariable from 'static/fonts/PretendardVariable.woff2'; + +export const ChatRoomBackgroundColor = '#93aad4'; +export const BackgroundColor = '#fafafa'; + +export const GlobalStyle = styled.createGlobalStyle` + ${reset} + + #root { + --Background-White: #fafafa; + --Blue: ${ChatRoomBackgroundColor}; + --Green: #ace49b; + --Gray-1: #f5f5f5; + --Gray-2: #8f8f8f; + --Gray-3: #5a5a5a; + --gray-30: rgba(0, 0, 0, 0.3); + } + + html, + body, + #root { + font-family: 'Pretendard Variable', sans-serif; + width: 100%; + height: 100%; + } + + * { + // 이유는 모르겠지만, 위랑 아래 이렇게 둘 다 폰트 지정을 해줘야 데스크탑 모바일 환경에서 모두 정상적으로 폰트 적용됨 + font-family: 'Pretendard Variable', sans-serif; + font-weight: 400; + box-sizing: border-box; + } + + button { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: transparent; + border: none; + margin: 0; + padding: 0; + } + + input:focus, + textarea:focus { + outline: none; + } + input::placeholder, + textarea::placeholder { + font-family: 'Pretendard Variable'; + } + + @font-face { + font-family: 'Pretendard Variable'; + font-weight: 45 920; + font-style: normal; + font-display: block; + src: url(${PretendardVariable}) format('woff2-variations'); + } +`; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..4b53c78 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,28 @@ +export interface TMessage { + id: number; + fromUserId: number; + toUserId: number; + text: string; + time: string; + isRead: boolean; + likeCount: number; +} + +export interface TUser { + id: number; + name: string; + profileImage: string | null; + statusMessage: string | null; + likedMessages: number[]; + github: string | null; + behance: string | null; + instagram: string | null; +} + +export interface TChatRoomInfo { + id: number; + profileImage: string | null; + userName: string; + message: string; + time: string; +} diff --git a/src/utils/fileCompression.ts b/src/utils/fileCompression.ts new file mode 100644 index 0000000..afb2609 --- /dev/null +++ b/src/utils/fileCompression.ts @@ -0,0 +1,13 @@ +// 프로필 사진의 크기가 너무 커서, localStorage에 overflow가 발생하는 것을 방지하기 위한 압축 기능 + +import imageCompression from 'browser-image-compression'; + +export const CompressImage = async (image: File) => { + const options = { + maxSizeMB: 0.01, + maxWidthOrHeight: 1920, + useWebWorker: true, + }; + const compressedFile = await imageCompression(image, options); + return compressedFile; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..3554d98 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,127 @@ +import { TMessage } from 'types'; +import userData from 'data/userData.json'; +import { include } from 'utils/search'; + +const daySelector = ['Sun', 'Mon', 'Tues', 'Wed', 'Thur', 'Fri', 'Sat']; +const monthSelector = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +// 채팅버블 시간 formatting +export const convertTimeFormat = (date: string) => { + const dateObj = new Date(date); + let amPm = 'AM'; + let hour = dateObj.getHours(); + if (hour > 12) { + hour -= 12; + amPm = 'PM'; + } + if (hour === 0) hour = 12; + return `${hour}:${String(dateObj.getMinutes()).padStart(2, '0')} ${amPm}`; +}; + +// 채팅목록 시간 formatting +export const convertTimeFormatForChatRoom = (date: string) => { + const dateObj = new Date(date); + + let converted = ''; + if ( + new Date().getFullYear() === dateObj.getFullYear() && + new Date().getMonth() === dateObj.getMonth() && + new Date().getDate() === dateObj.getDate() + ) { + let hour = dateObj.getHours() % 12; + const amFlag = dateObj.getHours() < 12; + if (hour === 0) { + hour = 12; + } + + converted = `${amFlag ? '오전' : '오후'} ${hour}:${String( + dateObj.getMinutes() + ).padStart(2, '0')}`; + return converted; + } + if (new Date().getFullYear() > dateObj.getFullYear()) { + converted = converted.concat(`${dateObj.getFullYear()}`); + } + converted = converted.concat( + `${dateObj.getMonth() + 1}/${String(dateObj.getDate()).padStart(2, '0')}` + ); + + return converted; +}; + +// 채팅방 날짜 formatting +export const convertDayDateFormat = (originalDate: string) => { + const dateObj = new Date(originalDate); + const day = daySelector[dateObj.getDay()]; + const month = monthSelector[dateObj.getMonth()]; + const date = dateObj.getDate(); + + return `${day}, ${month} ${date}`; +}; + +export const checkIsNextDay = (date1: string, date2: string) => { + const dateObj1 = new Date(date1); + const dateObj2 = new Date(date2); + if ( + dateObj1.getFullYear() === dateObj2.getFullYear() && + dateObj1.getMonth() === dateObj2.getMonth() && + dateObj1.getDate() === dateObj2.getDate() + ) + return false; + return true; +}; + +// 채팅목록 구성을 위한 각 유저와의 마지막 메시지 구하기 +export const getLastMessages = (id: number, messages: TMessage[]) => { + const lastMessages: TMessage[] = []; + const checkFirst = Array(userData.data.length + 1).fill(0); + + [...messages].reverse().forEach((message) => { + if (message.fromUserId === id || message.toUserId === id) { + const opponent = + message.fromUserId === id ? message.toUserId : message.fromUserId; + + if (!checkFirst[opponent]) { + lastMessages.push(message); + checkFirst[opponent] = 1; + } + } + }); + + return lastMessages; +}; + +// 검색된 유저 목록 +export const getSearchedUsers = (userId: number, query: string) => { + const storedUserData = userData.data.filter( + (e) => e.id !== userId && include(e.name, query) + ); + // 유저 데이터가 초기 상태에서 변경되어 localStorage에 저장되어 있다면 해당 데이터로 교체 + for (let i = 0; i < storedUserData.length; i += 1) { + const data = localStorage.getItem(`user_${storedUserData[i].id}`); + if (data) { + storedUserData[i] = JSON.parse(data); + } + } + + // 사전순 정렬 + storedUserData.sort((a, b) => { + if (a.name < b.name) return -1; + else return 1; + }); + + return storedUserData; +}; diff --git a/src/utils/search.ts b/src/utils/search.ts new file mode 100644 index 0000000..228a946 --- /dev/null +++ b/src/utils/search.ts @@ -0,0 +1,54 @@ +// 한글 초성 검색을 가능하게 하는 기능, 그냥 귀찮아서 구글링해서 찾음 + +const CHO_HANGUL = [ + 'ㄱ', + 'ㄲ', + 'ㄴ', + 'ㄷ', + 'ㄸ', + 'ㄹ', + 'ㅁ', + 'ㅂ', + 'ㅃ', + 'ㅅ', + 'ㅆ', + 'ㅇ', + 'ㅈ', + 'ㅉ', + 'ㅊ', + 'ㅋ', + 'ㅌ', + 'ㅍ', + 'ㅎ', +]; + +const HANGUL_START_CHARCODE = '가'.charCodeAt(0); + +const CHO_PERIOD = Math.floor('까'.charCodeAt(0) - '가'.charCodeAt(0)); +const JUNG_PERIOD = Math.floor('개'.charCodeAt(0) - '가'.charCodeAt(0)); + +function combine(cho: number, jung: number, jong: number) { + return String.fromCharCode( + HANGUL_START_CHARCODE + cho * CHO_PERIOD + jung * JUNG_PERIOD + jong + ); +} + +function makeRegexByCho(search = '') { + const regex = CHO_HANGUL.reduce( + (acc, cho, index) => + acc.replace( + new RegExp(cho, 'g'), + `[${combine(index, 0, 0)}-${combine(index + 1, 0, -1)}]` + ), + search + ); + + return new RegExp(`(${regex})`, 'g'); +} + +export function include(target: string, query: string) { + if (!query) return target; + if (/[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(query)) + return makeRegexByCho(query).test(target); + else return target.toLowerCase().includes(query.toLowerCase()); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5211c0a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2016", + "lib": ["dom", "dom.iterable", "esnext"], + "baseUrl": "src", + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "src/custom.d.ts"] +}