diff --git a/.gitignore b/.gitignore index 4d29575..448863b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + 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/craco.config.js b/craco.config.js new file mode 100644 index 0000000..9f9c45d --- /dev/null +++ b/craco.config.js @@ -0,0 +1,14 @@ +const { CracoAliasPlugin } = require('react-app-alias'); + +module.exports = { + plugins: [ + { + plugin: CracoAliasPlugin, + options: { + source: 'tsconfig', + baseUrl: './src', + tsConfigPath: './tsconfig.paths.json', + }, + }, + ], +}; diff --git a/layout.md b/layout.md new file mode 100644 index 0000000..fc4f0fa --- /dev/null +++ b/layout.md @@ -0,0 +1,19 @@ +# 문서 목적 + +> 본 문서는 전체적인 웹 페이지의 레이아웃에 대한 이해를 돕고자 작성되었습니다. + +## 상단 고정 + +1. ChatHead => 채팅방 페이지. 내부에 `IphoneStatusBar` 컴포넌트가 위치하며 고정적으로 47px의 높이를 가짐. 그 다음에 `ChatHeadNav` 컴포넌트가 위치함(이건 flex-grow를 다 먹어버림) + +## 하단 고정 + +1. 매번 나타나는 것은 `HomeIndicator`임. 이는 부모 컴포넌트인 main이 relative에 맞춰 `position: absolute` 속성을 가지며 그 위에 와야하는 것들은 기존의 문서 흐름에 고대로 있기에 `HomeIndicator` 높이 만큼의 padding-bottom 속성을 부여받는다. +2. `HomeIndicator`위에 바로 오는 것은 Input이 될 수도 있고 tab Bar가 될 수도 있다. 따라서 이때에는 유도리있게 1의 논리를 적용하여 padding-bottom을 적극 활용한다(홈 인디케이터 바는 계속 고정적으로 있으니까). + +## 중앙에 위치하는 컨텐츠 + +1. 일단 main 부모 컴포넌트의 문서 흐름에 맞추어 생각해보았을 때 `flex-grow: 1`로서 본인이 남은 칸을 모두 먹어버리고 `overflow-y: scroll` 속성을 통해 넘치면 스크롤 될 수 있게 만든다. +2. 나머지 속성들은 margin을 계산해서 넣어준다. + +## diff --git a/make.md b/make.md new file mode 100644 index 0000000..198ee5e --- /dev/null +++ b/make.md @@ -0,0 +1,62 @@ +# 구현을 해야할 기능 + +- 사용자가 input에 focus를 하면 전체의 너비가 줄어들어야 함 +- 기본적으로 모바일 뷰를 기준으로 하기에 전체 너비를 375px, 높이를 812로 제한하고 시작하면 됨 +- 기존의 사용자를 토글하면 사용자가 바뀌고 채팅이 뒤바뀌어서 보여야 함. => me와 other 속성 2개를 선언함 +- 기존의 메시지가 많아지면 자연스럽게 스크롤이 되어야 함 => `overflow : scroll` 속성을 통해서 해결 +- 디자인 시스템: 자주 사용되는 디자인 속성을 변수와 같은 시스템으로 구현해야 함 +- 더블 클릭하면 하트 감정 표시가 보여질 수 있어야 함 => 나중에 디자이너 분께서 단순 하트가 아닌 따봉, 웃는 사람의 얼굴 등을 보여주는 기능을 요구할 수도 있음 +- 추가적으로 본인의 메시지에는 더블 클릭을 하더라도 하트가 적용이 되면 안됨 -> 더블 클릭을 어떻게 인지할 것인가? 애초에 dom 속성 중에서 `domDoubleClick` 속성이 존재함 +- 하트를 구현해야 함 -> 하트가 붙어야 하는지 아닌지를 boolean 타입의 상태로 선언해(typescript), 조건부 렌더링을 진행 => 꼭 상태로 선언할 필요는 없음 어차피 localStorage, json 파일을 모두 사용할 것이므로 그냥 객체의 속성에 boolean 필드를 삽입하는 방식으 택하기 +- Typescript 적용 +- 사용자가 아직 아무것도 입력하지 않았을 경우 메시지 전송 버튼은 비활성화 되어 있어야 함 --> **상태**를 통해 UI를 다르게 렌더링 해주어야 함 +- 하단 input에서 + 버튼을 누르면 + 가 이미지, 음성 이미지로 변환될 필요가 있음 input 박스 영역은 `flex - grow : 1` 속성을 부여하여 남는 부분을 모두 차지하도록 함 +- 사용자가 제시한 날짜에 따라서 날짜 표시가 있고, 그 다음에 메시지들이 진행되어야 함. +- 카카오톡, 라인과 다르게 왼쪽 오른쪽으로 상대방과의 메시지가 엇갈려서 나타나는 것이 아니라, 왼쪽에 항상 몰려있는 구조임. 메시지의 길이에 따라서 메시지 박스 높이가 조절이 되도록 `height: 속성은 auto` 를 부여하는 것이 좋음. 아니면 `fit-content` 속성도 나쁘지 않음 +- homeIndicator는 채팅 입력 박스가 올라오더라도 아래에 고정되어 있어야 함 + +### 나누면 좋을만한 파일 + +- 기본적으로 styled-components 모듈을 사용할 것이기 때문에 정민님꼐서 Iphone, Images, TabBar, Gloabl, FriendPage, ProfilePage, ChatroomPage 등으로 나눠놓은 컴포넌트를 따로 파일로 분리하고 export하면 이를 import하여 객체 지향형 느낌으로 사용 + +### 디자인 시스템 + +- 자주 사용되는 색상들을 변수로 활용하고 여기에는 동일한 색상이더라도 opacity의 개념이 도입될 수도 있음 +- styled-components나, styled-system 모듈은 디자인 시스템을 구축할 수 있게 도와준다 using ThemeProvider나 후자의 utility first 클래스명을 이용! + +### 컴포넌트 분리의 기준 + +- 일단 figma에서 제시한 컴포넌트들을 최대한 복사 + 붙여넣기 식으로 활용하고, 추후에 활용을 할 떄 제대로 적용이 안된 것들은 다시 `styled()` 함수를 이용하여 다시 스타일링 진행 +- 그냥 단순 이미지를 렌더링해주는 컴포넌트를 imageComponent 라는 디렉터리에 한 파일로 그냥 싸악 적용해주고 끝낸다. 나중에 이를 활용하는 헤더 등의 컴포넌트에서 이들을 받아서 사용한다 +- 매 화면에 고정적으로 등장하는 iphone Status Bar(시간, 와이파이, 배터리를 가지고 있는 헤더)와 homeIndicator는 따로 fixed 컴포넌트로 만들어두고 매번 활용하는 구조. 가장 위에는 `iphoneStatusBar`, 가장 아래에는 `homeIndicator(z-index를 높게 설정)`가 위치하는 구조 => 같은 div의 자식 컴포넌트로 위치시키기 +- 해당 컴포넌트가 상황에 따라서 위치나 크기 등이 가변적인지 아닌지에 따라서 `header`, `AppMain`, `footer`로 나뉘어야 함 + +### 정민님 figma 기본 구조 + +- 기본적으로 항상 많이 쓰이는 것들은 fixed, 유동적으로 구성되는 요소들은 scroll이라고 표현하심 +- input box에 포커싱이 되면 위의 고정 헤더들은 가만히 있고, 나머지 전체 요소들의 높이가 자연스럽게 줄어들어야 한다 + +### 사용자 시나리오 + +1. 전체 탭 바를 통해 이동할 수 있는 구간은 3군데임 : 친구 & 메시지 & 내 프로필 +2. 친구와의 메시지 탭을 누르면 바로 메시지 url로 넘어감 : `message/{친구명}`의 주소로 처리됨 +3. <- arrow를 통해서 이전의 `/message` 주소로 리디렉션됨. + +### 해결 포인트(trouble shooting) + +1. input 박스 옆에 웃는 아이코은 span 태그 내부에 input과 이모지를 flex item으로 구성하며 해당 길이는 따라서 조정해줘야 함 => 아예 컨테이너를 만들어서 그 안에 input 요소를 넣고 이모지는 absolute 속성으로서 해결함 +2. focused가 된 상태에서 제출 버튼을 누르면 폼이 제출 되는 것이 아니라 그냥 입력해제가 되어버림. onBlur가 먼저 트리거 됨 => `handleToggleIsInputBoxFocused` 함수의 내부 조건부 로직으로 바꿔줌 +3. 처음에 로컬스토리지에 접근했는데 없다면 상태를 로컬 스토리지에 넣어줘야하고 이미 있다면 상태를 그걸로 적용해줘야 됨 => 해결 `useEffect` 훅 내에서 분기로 처리함 +4. useNumber는 일단 처음은 1로 설정되고 내 메시지는 노란색으로, 상대방은 초록색으로 보인다. => 해결 +5. 사용자가 메시지를 입력하면 기존 상태 2개(data와 date 2개)를 바꾸고 이를 로컬 스토리지에도 반영해야한다 => 해결 +6. 메시지를 보내면 바로 스크롤되어 내려옴. useRef를 이용해서 scrollTop 속성을 scrollHeight로 바꿔서 아래로 내리면 되는데, chatBody와 chatInput은 형제 속성이라서 함께 건드리기 어려움 => 전역 상태로 관리 +7. 더블 클릭하면 하트가 생겨야 하는데, 남의 것에만 할 수 있어야 한다 => 해결 +8. 여러 사용자들을 구분할 수 있어야 함. 일단 "나는" 김정민임. 따라서 userMode는 당연히 처음에 1로 들어가는 것이 맞긴함. 로컬 스토리지에 저장할 데이터를 `chatMessageData_{유저id숫자}`, `chatMessageDateArray_{유저 id 숫자}` 방식으로 해결하자 +9. 클릭해서 들어가는 방식 말고, url path를 직접 쳐서 들어가는 경우는 클릭해서 간 것이 아니기에 tab bar 속성이 제대로 동작하지 않는다는 문제가 발생 => onclick 속성으로 하면 안됨 : `useEffect()` 훅을 따로 사용하지 않고 그냥 `useLocation()` 훅으로 받은 path를 검사해서 상태를 변경해주었음 + +### 각 페이지 별 기능 + +1. `/friends` : 그냥 친구가 누가 있는지를 보여주기만 하면됨 일단은. (단순 UI) => 일단 애플리케이션이 켜지면 바로 들어갈 수 있게 url path를 `/`로 설정하기 +2. `/messagess` : 채팅 목록들을 보여주고, 가장 마지막 데이터에 해당하는 사람과 메시지를 보여줄 필요가 있음 +3. `/message/{특정 user id}` : 해당 내용에 해당하는 사람꺼를 보여줌. 나중에 `useParams()` 훅을 이용하여 진행할 수 있음 +4. `/profile` : 본인의 프로필을 조회할 수 있음(단순 UI) diff --git a/package-lock.json b/package-lock.json index c27bbe4..d55ebf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "react-messenger-19th", "version": "0.1.0", "dependencies": { + "@craco/craco": "^7.1.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -15,11 +16,21 @@ "@types/node": "^16.18.91", "@types/react": "^18.2.69", "@types/react-dom": "^18.2.22", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "recoil": "^0.7.7", + "styled-components": "^6.1.8", + "styled-reset": "^4.5.2", "typescript": "^4.9.5", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "@types/lodash": "^4.17.0", + "react-app-alias": "^2.2.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2018,6 +2029,49 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" }, + "node_modules/@craco/craco": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@craco/craco/-/craco-7.1.0.tgz", + "integrity": "sha512-oRAcPIKYrfPXp9rSzlsDNeOaVtDiKhoyqSXUoqiK24jCkHr4T8m/a2f74yXIzCbIheoUWDOIfWZyRgFgT+cpqA==", + "dependencies": { + "autoprefixer": "^10.4.12", + "cosmiconfig": "^7.0.1", + "cosmiconfig-typescript-loader": "^1.0.0", + "cross-spawn": "^7.0.3", + "lodash": "^4.17.21", + "semver": "^7.3.7", + "webpack-merge": "^5.8.0" + }, + "bin": { + "craco": "dist/bin/craco.js" + }, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "react-scripts": "^5.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/normalize.css": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", @@ -2288,6 +2342,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.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", + "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + }, "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", @@ -3338,6 +3410,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", + "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", + "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", @@ -3974,6 +4054,26 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.10.tgz", + "integrity": "sha512-PiaIWIoPvO6qm6t114ropMCagj6YAF24j9OkCA2mJDXFnlionEwhsBCJ8yek4aib575BI3OkART/90WsgHgLWw==" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4161,6 +4261,12 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "node_modules/@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4290,6 +4396,11 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "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/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", @@ -5750,6 +5861,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", @@ -5904,6 +6023,19 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6144,6 +6276,29 @@ "node": ">=10" } }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-1.0.9.tgz", + "integrity": "sha512-tRuMRhxN4m1Y8hP9SNYfz7jRwt8lZdWxdjg/ohg5esKmsndJIn4yT96oJVcf5x0eA11taXl+sIp+ielu529k6g==", + "dependencies": { + "cosmiconfig": "^7", + "ts-node": "^10.7.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=7", + "typescript": ">=3" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -6182,6 +6337,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", @@ -6372,6 +6535,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", @@ -6635,6 +6808,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -6836,6 +7014,14 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -8304,6 +8490,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -8861,6 +9055,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hamt_plus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", + "integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -9636,6 +9835,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -9813,6 +10023,14 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -12302,6 +12520,11 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -14696,6 +14919,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-app-alias": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-app-alias/-/react-app-alias-2.2.2.tgz", + "integrity": "sha512-mkebUkGLEBA8A8jripu5h1e3cccGl8wWHCUmyJo43/KhaN91DO3qyCLWGWneogqkG4PWhp2JHtlCJ06YSdHVYQ==", + "dev": true + }, "node_modules/react-app-polyfill": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", @@ -14864,6 +15093,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", + "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "dependencies": { + "@remix-run/router": "1.15.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", + "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "dependencies": { + "@remix-run/router": "1.15.3", + "react-router": "6.22.3" + }, + "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", @@ -14968,6 +15227,25 @@ "node": ">=8.10.0" } }, + "node_modules/recoil": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz", + "integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==", + "dependencies": { + "hamt_plus": "1.0.2" + }, + "peerDependencies": { + "react": ">=16.13.1" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/recursive-readdir": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", @@ -15719,6 +15997,22 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "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", @@ -16260,6 +16554,81 @@ "webpack": "^5.0.0" } }, + "node_modules/styled-components": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.8.tgz", + "integrity": "sha512-PQ6Dn+QxlWyEGCKDS71NGsXoVLKfE1c3vApkvDYS5KAK+V8fNWGhbSUEo9Gg2iaID2tjLXegEW3bZDUGpofRWw==", + "dependencies": { + "@emotion/is-prop-valid": "1.2.1", + "@emotion/unitless": "0.8.0", + "@types/stylis": "4.2.0", + "css-to-react-native": "3.2.0", + "csstype": "3.1.2", + "postcss": "8.4.31", + "shallowequal": "1.1.0", + "stylis": "4.3.1", + "tslib": "2.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, + "node_modules/styled-reset": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/styled-reset/-/styled-reset-4.5.2.tgz", + "integrity": "sha512-dbAaaVEhweBs2FGfqGBdW6oMcMK8238C2X5KCxBhUQJX92m/QyUfzRADOXhdXiXNkIPELtMCd72YY9eCdORfIw==", + "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", @@ -16275,6 +16644,11 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -16762,6 +17136,61 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -17129,6 +17558,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -17495,6 +17929,19 @@ "node": ">=10.13.0" } }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/webpack-sources": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", @@ -17675,6 +18122,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -18164,6 +18616,14 @@ "node": ">=10" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ea335d3..31e978c 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@craco/craco": "^7.1.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -10,17 +11,25 @@ "@types/node": "^16.18.91", "@types/react": "^18.2.69", "@types/react-dom": "^18.2.22", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "recoil": "^0.7.7", + "styled-components": "^6.1.8", + "styled-reset": "^4.5.2", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "start": "craco start", + "build": "craco build", + "test": "craco test", + "eject": "craco eject", + "serve": "serve -s build", + "push": "git push origin Programming-Seungwan:Programming-Seungwan" }, "eslintConfig": { "extends": [ @@ -39,5 +48,9 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/lodash": "^4.17.0", + "react-app-alias": "^2.2.2" } } diff --git a/pr.md b/pr.md new file mode 100644 index 0000000..628e830 --- /dev/null +++ b/pr.md @@ -0,0 +1,58 @@ +> # 배포 링크 +> +> [배포 링크](https://react-messenger-19th-seungwan-wg21.vercel.app/) + +## 📌 구현 기능 + +1. 피그마에 나타난 화면 UI 구현 => `styled-components`의 `ThemeProvider`를 통해 디자인 시스템 구축 +2. 기존의 **json** 파일에 나타난 데이터를 보여주거나 이미 데이터가 존재한다면 이를 보여주기 +3. 사용자가 채팅을 입력하고, 기존의 데이터에 뒤이어 UI로 구현 => 채팅 입력 시, 채팅창의 최하단으로 포커스 이동 +4. 사용자가 이미 보낸 상대방의 메시지를 더블 클릭하면 하트 UI가 생성되고, 다시 더블 클릭하면 사라짐 +5. 채팅방 상단의 프로필을 클릭하면 상대방으로 사용자가 바뀜 + +## 🧠 느낀 점 및 시간 투자 부분 + +### 시간 투자 💪🏼 + +1. 절대 경로를 이용하기 위해 `tsconfig.json` 파일을 이용하려 했지만, CRA의 기본 동작상 이를 그냥 무시하기에 `webpack` 빌드 툴의 설정을 바꿔야 했습니다. 이를 `craco`(CRA configuration Overriding)JS의 설정을 하는데에 시간 소요가 다소 걸렸습니다.
+2. 기존의 state를 이용한 prop drilling이나 `contextAPI`를 사용해도 좋지만 recoilJS 전역 상태관리 라이브러리를 사용하니 쉬웠던 것 같습니다.
+3. 새로 고침을 해주어도 기존의 사항이 반영되도록 localStorage를 이용했습니다. 로컬 스토리지에 기존의 데이터가 없다면 json 파일의 데이터를 상태로 정하고 이를 로컬 스토리지에 반영하며, 기존의 데이터가 존재한다면 상태로 이용하는 분기적인 로직을 구현했습니다. 다만, 추후에 메시지의 추가나 더블 클릭을 통한 좋아요 하트 표시의 생성과 삭제 시에 객체가 `readOnly` 속성인 점을 유의해야 했던 것 같습니다. 이를 위해 스프레드 연산자를 사용하지 못하는 경우도 많았습니다. 스프레드 연산자를 사용하면 중첩된 속성이 다시 객체 혹은 배열이라면 `deepcopy`가 되지 않기 때문에 `JSON.parse(JSON.stringify({기존 객체}))`방식을 이용했습니다.
+4. 사용자가 input 박스에 focus 했을을 판단하기 위해 `isInputBoxFocused` 속성을 이용했습니다. 기존에 풀려있다가 focus되면 true로 만들어주는 경우, 아직 focus 안됐는데 제출 버튼을 클릭한 경우(이는 무시됨), focus 되었는데 메시지가 없는 경우(무시됨), focus 되었는데 제출 버튼을 클릭했지만 이것이 메시지를 담는 경우에 onBlur 때문에 발동되는 함수를 조기에 `return`시키고 다음의 onSubmit 트리거 함수에서 data 상태와 로컬스토리지를 업데이트 시킨 뒤 그 다음 `isInputBoxFocused` 속성을 `false`로 바꿔주는 로직을 작성했습니다. +5. 메시지를 추가하고 스크롤을 제일 아래로 내리는 것을 DOM 요소의 `scrollTop`과 `scrollHeight` 속성을 이용하여 구현하고자 했습니다. 하지만 이는 추가적인 메시지 DOM 요소가 생성되기 이전에 트리거 되므로 기존의 DOM 요소 높이만을 계산합니다. 따라서 이를 `messageData` 상태가 배뀌면 바로 트리거 되도록 `chatBody` 컴포넌트 내의 `useEffect` 훅을 이용하여 구현해주니 해결되었습니다.

+ +### 느낀 점 ❗️ + +1. 기존에 심미적으로 깔끔하고 이쁜 레이아웃을 만드는 것보다는 다른 방면으로 UX를 향상시키는 것에 관심이 많은 편이기에, 디자이너 분께서 UI 초안을 제시해주셔서 상당히 좋았던 것 같습니다. 또한 UI 컴포넌트마다 재사용될 수 있도록 분리해 놓으셔서 이를 활용해 재사용성을 높일 수 있었습니다. + +2. 어떤 상태를 설정하고 이를 어떻게 활용할지 미리 설계해두고 들어가면 참 좋겠다는 생각, 이를 위해서는 UI 컴포넌트의 단위를 잘 설정해야 한다는 것을 알 수 있었습니다. + +3. 타입스크립트를 사용하면 내가 에상하지 못하는 케이스를 시스템이 잡아줄 수 있다는 것을 알 수 있었습니다. + +## ❓ Key Questions + +- JavaScript를 사용할때에 비해 TypeScript를 사용할 때의 장점은 무엇인가요?
+ +> 타입스크립트를 사용하면 정적 타입 검사를 통해 에러를 런타임 이전에 방지할 수 있습니다.
+ +타입스크립트는 Javascript 언어의 superset으로서, tsc와 같은 컴파일러의 컴파일링을 통해 Javascript로 변환되어 실행됩니다. 타입 스크립트를 사용하면 `type alias`나 `interface`와 같은 개념을 통해 매개변수에 전달되는 인자의 타입을 강제할 수 있어 컴파일 시간에 오류를 잡아낼 수 있습니다. 이는 프로그래밍 작업 시에 vscode 단에서 오류를 미리 표시해주어 상당히 편리하다고 할 수 있습니다.
+특정 함수나 객체를 작성할 때 vscode 단에서 미리 매개변수나 속성들을 추천해주어 개발 생산성이 상당히 올라간다고 할 수 있습니다. + +- 디자이너로부터 전달받은 피그마 링크 혹은, 피그마 캡처본
+ +- 컴포넌트를 분리한 기준은 무엇인가요?
+ +크게 UI에서 고정이 되는 부분과, 유동적으로 바뀔 수 있는 부분에 대해서 생각을 많이 했던 것 같습니다. 화면에서 채팅의 개수가 늘어날 때마다 `chatBody`이 스크롤 되어야 함, 사용자가 `input box`에 focus 했을 때 왼쪽의 플러스 버튼이 사라지고, 다시 해제되었을 때에는 생성되는 것도 상태를 이용한 조건부 렌더링을 통해 UI를 구축했습니다.

+크게 `iphoneStatusBar`, `chatHeadNav`를 상단부에, 중간에 `chatBody`를, 아래에 `chatInput`과 `HomeIndicator`를 구성했고 마지막 `HomeIndicator` main 요소에 참조하여 항상 하단에 고정해주었습니다. + +> 다양한 UI 컴포넌트들은 `components`, recoil을 통한 상태 생성은 `context`, css 설정 관련해서는 `styles`, ts를 위한 공통적인 타입 정의는 `type`, 여기저기에서 쓰일 간단한 함수들은 `utils` 디렉터리에 구분하여 만들어 주었습니다. + +- 디자인 시스템을 적용하면서 느낀 점은 무엇인가요?
+ +디자인 시스템을 적용하며 느낀점은 바로 "재활용성"입니다. 디자이너 분께서 특정 컨셉을 활용하시기에, 중구 난방으로 튀는 디자인 원칙이 아닌 일관적인 UI 시스템은 사용자로 하여금 UX를 향상할 수 있습니다. 이를 `styled-components` 라이브러리의 시스템을 활용하니 손쉽게 사용할 수 있어 좋았습니다.
+ +다만, 스타일드 컴포넌트는 `css-in-js` 시스템이기 때문에 결국에는 Javascript 코드가 css 파일로 변환되어야함을 의미합니다. 이를 추후에 `nextJS`와 같은 풀스택 프레임워크와 함께 사용된다면 서버 사이드 렌더링이 진행될텐데 어떻게 활용할 수 있을지 궁금합니다. + +- 디자이너와 소통하며 느낀점은 무엇인가요?
+ +원래 figma 툴의 사용법에 대해서 거의 무지한 수준이었습니다. 하지만 디자이너 분께서 작성해주신 피그마 툴의 요소 간의 거리 측정 기능, 이미지 파일로의 전환 기능 등에 대해서 소개 받을 수 있었고 이해가 잘 가지 않는 것에 대해서도 질문할 수 있었습니다. 또한 매번 개인 카톡으로 연락 드리기보다는 comment 기능을 활용하여 제가 원하는 질문을 남겨놓으면 디자이너 분께서 가능하실 때 보시고 답변해주셔서 유연하다고 생각해습니다. +
diff --git a/pr2.md b/pr2.md new file mode 100644 index 0000000..2d953c4 --- /dev/null +++ b/pr2.md @@ -0,0 +1,105 @@ +> # 배포 링크 +> +> [배포 링크](https://react-messenger-19th-seungwan-wg21.vercel.app/) + +## 📌 구현 기능 + +1. 피그마에 나타난 화면 UI 구현 => `styled-components`의 `ThemeProvider`를 통해 디자인 시스템 구축 +2. 기존의 **json** 파일에 나타난 데이터를 보여주거나 이미 데이터가 존재한다면 이를 보여주기 +3. 사용자가 채팅을 입력하고, 기존의 데이터에 뒤이어 UI로 구현 => 채팅 입력 시, 채팅창의 최하단으로 포커스 이동 +4. 사용자가 이미 보낸 상대방의 메시지를 더블 클릭하면 하트 UI가 생성되고, 다시 더블 클릭하면 사라짐 +5. 채팅방 상단의 프로필을 클릭하면 상대방으로 사용자가 바뀜 +6. 나머지 피그마 디자인 페이지 구현 + +## 🧠 느낀 점 및 시간 투자 부분 + +### 시간 투자 💪🏼 + +url 라우팅을 진행하는 데에 시간을 꽤 투자했던 것 같습니다. 3주차의 과제가 react로 채팅방까지를 구현하는 것이라, 이를 루트(/) 디렉터리로 설정했습니다.
+이번 과제는 친구 목록, 채팅 목록, 채팅방 입장, 개인 프로필 페이지를 모두 구현해야 함과 동시에 기존의 채팅방을 녹여내는 것까지라 프로젝트 url 설계를 먼저 진행했습니다. +공통적으로 많이 사용되는 header와 tabBar 같은 경우에는 `CommonLayout` 컴포넌트에 넣어주고, 주소에 따라서 바뀌는 부분을 `Outlet` 컴포넌트를 통해 유동적으로 바꿔주었습니다.
+또한 사용자가 입장한 채팅방에서 채팅을 입력한 후, 새로 고침을 하거나 퇴장한 후 다시 입장해도 기존의 메시지 데이터가 남아있어야 하기에 이를 `localStorage`에 넣어주는 방식을 이용했습니다. + +### 느낀 점 ❗️ + +- 웹 애플리케이션의 전체적인 페이지 구조가 나와야 공통 레이아웃, 변화하는 Outlet 등을 적용하기 용이한 것 같습니다. 그 과정에서 기획자나 디자이너가 다른 페이지를 추가적으로 요구할 시를 위해 확장성 있는 설계가 필요할 것 같습니다. +- 현재 구현한 웹 애플리케이션은 사용자들의 이름이 서로 다름을 가정하고 있습니다. 하지만 실제 서비스에서는 동명이인이 존재할 가능성이 충분합니다. 이에 `localStorage`에 key 값으로 사용자명을 썼는데, 어떻게 하면 primary한 데이터로 바꿀 수 있을지 더 고민해보면 좋을 것 같습니다. +- 너무 세세한 상태까지 모두 `recoil`이나 `redux` 전역 상태 관리 라이브러리로 관리할 필요는 없을 것 같습니다. 사용자의 인터렉션에 따라 상태가 변화하고 관련 UI가 구현되는 정도라면 해당 상태는 컴포넌트에서 `useState()` 훅을 이용하는 것이 단일 책임의 원칙에도 더 맞을 것 같습니다. + +## ❓ Key Questions + +- Routing 이란?

+ 라우팅이란, 프론트엔드에서 웹 애플리케이션 내의 다양한 페이지를 탐색할 수 있도록 도와주는 기술이다. 네이버, 다음, 쿠팡, 배달의 민족과 같이 우리가 자주 사용하는 웹 사이트는 랜딩 페이지 이외에도 여러 세부 페이지로 나뉘어져 있다.
+ 기존의 html, css, vanilla Javascript 만을 이용하는 방식에서는 `a` 태그를 통해 특정 페이지로의 전환을 하고 해당 페이지에 알맞은 html 파일을 통해 사용자에게 적절한 UI를 렌더링한다.
+ 요즘 많이 사용되는 AngularJS(인기가 좀 죽긴 했다), vueJS, reactJS에는 **Angular-router**, **vue-router**, **react-router** 등의 방식으로 라우팅을 지원하는데, 이를 사용하지 않았을 때와 비교해서 살펴보면 좋다. + +``` +import "./styles.css"; +import { useState } from "react"; +import Home from "./Home"; +import About from "./About"; +import NotFound from "./Not-found"; + +export default function App() { + const [comp, setComp] = useState(Home); + + return ( +
+ + + +
+
+ ); +} +``` + +위와 같은 방식은 react 코드 단에서 react-router-dom의 도움 없이 상태(state)만으로 라우팅을 진행하고 있다. 버튼을 누르면 상태가 변경되어 `main` 태그 내에서 렌더링되는 children 속성 컴포넌트가 바뀔 수 있게 한다. 하지만 이와 같은 로직은 페이지가 추가될 때마다 관련 핸들러 함수를 만드는 등 복잡도가 증가할 수 있다. 이를 위해 react는 `react-router-dom` 모듈의 Router 개념을 통해 해결한다 +

+ +``` +function App() { + return ( + + + + + + }> + }> + }> + }> + + }> + }> + } /> + + + + + + ); +} +``` + +위의 코드는 react-router-dom 모듈의 `BrowserRouter`, `Routes`, `Route` 컴포넌트 들을 이용해 app.tsx 파일 내에서 라우팅을 진행해준 것이다(ThemeProvider와 RecoilRoot 컴포넌트는 각각 디자인 시스템, 전역 상태 관리를 위해 존재하는 wrapper 컴포넌트이니 신경 쓰지 않아도 된다).
+이와 같은 구조를 이용하면 웹 사이트의 도메인 주소의 특정 `url path`로 사용자가 접근하면 Route 컴포넌트의 element 속성으로 바인딩 된 컴포넌트가 렌더링 되는 구조인 것이다.
+하지만 이와 같은 라우팅 방식은 ReactJS 같은 CSR(클라이언트 사이드 렌더링) 방식의 웹 페이지에서 보이는 특성이고, nextJS나 Remix 같은 SSR(서버 사이드 렌더링), SSG(Static Site Generation)을 지원하는 풀스택 프레임워크에서는 주로 src 디렉터리 내부의 폴더 구조를 통해 라우팅을 진행한다. 즉, 디렉터리가 하나의 url path에 대응하는 것이다. + +- SPA 란?

+ SPA는 **Single Page Application**의 약자로, 특정 웹 애플리케이션이 구동될 떄 하나의 html 파일만을 기반으로 하여 작동한다는 것이다. reactJS를 이용하여 프로젝트를 진행할 때, CRA이면 public 디렉터리 내에 index.html이, vite를 이용하면 루트 디렉터리에 index.html 파일 하나만이 존재하는 것을 볼 수 있다. 해당 파일을 들여다보면 `body` 컴포넌트 내에 **id가 root인 div 태그** 하나 만이 외롭게 있는 것일 알 수 있다.
+ 이는 위에서 말한 react의 라우팅 방식에 의해 매 페이지에 해당하는 컨텐츠들이 id 가 root인 태그 내에 채워지며 상황마다 바뀌기 때문이다. [뉴진스의 홈페이지](https://newjeans.kr/)에서 [페이지 소스를 살펴보면](view-source:https://newjeans.kr/) 클라이언트(브라우저)에 전달된 리소스는 텅 빈 html 파일만이 전달된 것을 알 수 있다.
+ SPA는 세부 페이지마다 바뀌는 내용을 AJAX(Asnycronous Javascript and Xml)라는 기술을 통해 필요한 부분의 데이터에 대한 요청을 실행하여 렌더링하기 때문에 html 문서 자체가 바뀌면서 생기는 full page refresh와 같은 현상을 막을 수 있다. 이는 페이지 간의 부드러운 전환으로 사용자에게 더 나은 UX를 선사할 수 있다. 하지만 위에서 언급한 바와 같이 처음 클라이언트에게 전달되는 html 문서에는 아무런 컨텐츠가 존재하지 않기 때문에 검색 엔진 최적화(SEO)의 측면에서는 다소 불리하다고 할 수 있다.
+ react 같은 SPA 기술이 등장 이전에는 Java의 서블릿과 같은 개념, 여러 템플릿 엔진을 통해 사용자의 동적 요청에 따른 웹 페이지 반환 **SSR(Server Side Rendering)** 와 미리 만들어둔 컨텐츠가 자주 변하지 않는 정적 컨텐츠를 생성하는 **SSG(Static Site Generation)** 개념이 주를 이루었다. 이는 복수의 html 파일을 사용자에게 전달할 수 있다는 점에서 MPA(Multi Page Application)이라고 하며, 미리 컨텐츠가 채워져 있어 검색 엔진 최적화를 할 수 있지만 페이지 전환 시 뚝뚝 끊기는 현상이 발생한다는 단점이 있다.
+ + 위의 두 방식의 단점을 보완하고 장점을 챙기는 방식으로 나온 것들이 바로 nextJS와 nuxtJS 같은 풀스택 프레임워크 개념이다. 이들은 페이지, 컴포넌트 기반의 SSG, SSR 방식의 구현을 지원함과 동시에 웹 페이지의 변경되는 부분에 대한 추가적인 네트워크 데이터 요청으로 부드러운 페이지 전환 효과를 기대할 수 있다. + +- 상태 관리란?

+ 별도의 상태 관리 라이브러리를 사용하지 않아도 react 프로젝트를 진행할 수 있다. 이 경우에는 상태가 필요한 컴포넌트에서 `useState()` 훅을 통해 상태를 선언하여 UI를 렌더링 하면 된다. 하지만 복수의 컴포넌트에서 하나의 상태를 통해 렌더링 해야하는 로직이 존재한다면 상태 끌어올리기(state lifting)이 필요하다. 이는 해당 컴포넌트들이 공통적으로 가지는 부모 컴포넌트, 혹은 그 상위의 컴포넌트로 상태의 선언을 끌어올린 후,상태를 prop으로 계속해서 전달하여 사용하는 것이다. 단순한 프로젝트에서는 큰 상관이 없겠지만 이는 여러 문제점을 내포하고 있다.
+ + 1. 계속되는 **prop drilling**으로 프로그래머가 일일히 적어줘야 하고, 오타와 같은 human error가 발생할 수 있다. + 2. 상태가 변화할 때, 여러 컴포넌트를 통해 상태가 전달된다면, 그 중간에 있는 관련 없는 컴포넌트까지 리렌더링 되는 성능 비효율이 발생한다. + 3. 추후에 상태 관련 코드를 변경할 일이 생겼을 때, 하나의 상태에 여러 컴포넌트가 묶여있는 coupling 현상은 유지 보수를 어렵게 만든다 -> 단일 책임 원칙(SRP : Single Responsibility Principle)의 위반 +
+ + 이를 위해 contextAPI 같은 reactJS 자체의 방식, flux 패턴을 적용한 redux 라이브러리, atomic 패턴의 recoilJS와 그 이외의 mobx, zustand, jotai 등을 활용하면 보다 더 편리하게 상태 관리를 진행할 수 있다. diff --git a/public/Dummy/Dummy.json b/public/Dummy/Dummy.json new file mode 100644 index 0000000..3a95ad7 --- /dev/null +++ b/public/Dummy/Dummy.json @@ -0,0 +1,38 @@ +[ + { + "from": 2, + "like": false, + "content": "동해물과 백두산이 마르고 닳도록 하느님이 보우하사 우리나라 만세", + "createdAt": "2024-03-19T20:45:01.000Z" + }, + { + "from": 1, + "like": false, + "content": "무궁화 삼천리 화려강산", + "createdAt": "2024-03-19T20:48:02.000Z" + }, + { + "from": 2, + "like": false, + "content": "대한사람 대한으로 길이 보전하세", + "createdAt": "2024-03-19T20:49:03.000Z" + }, + { + "from": 1, + "like": false, + "content": "남산 위에 저 소나무 철갑을 두른 듯 바람서리 불변함은 우리 기상일세 무궁화 삼천리 화려강산", + "createdAt": "2024-03-19T20:51:04.000Z" + }, + { + "from": 2, + "like": false, + "content": "대한사람 대한으로 길이 보전하세", + "createdAt": "2024-03-19T20:52:05.000Z" + }, + { + "from": 1, + "like": false, + "content": "가을 하늘 공활한데 높고 구름 없이 밝은 달은 우리 가슴 일편단심일세 무궁화 삼천리 화려 강산 대한 사람 대한으로 길이 보전하세", + "createdAt": "2024-03-20T01:32:06.000Z" + } +] diff --git a/public/images/GitHub.svg b/public/images/GitHub.svg new file mode 100644 index 0000000..4a0efcc --- /dev/null +++ b/public/images/GitHub.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/HomeIndicator.svg b/public/images/HomeIndicator.svg new file mode 100644 index 0000000..94776d8 --- /dev/null +++ b/public/images/HomeIndicator.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/Instagram.svg b/public/images/Instagram.svg new file mode 100644 index 0000000..3037155 --- /dev/null +++ b/public/images/Instagram.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/Wifi.svg b/public/images/Wifi.svg new file mode 100644 index 0000000..02ad206 --- /dev/null +++ b/public/images/Wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/_StatusBar-battery.svg b/public/images/_StatusBar-battery.svg new file mode 100644 index 0000000..2a2b77b --- /dev/null +++ b/public/images/_StatusBar-battery.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/blueSignal.svg b/public/images/blueSignal.svg new file mode 100644 index 0000000..c831819 --- /dev/null +++ b/public/images/blueSignal.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/call.svg b/public/images/call.svg new file mode 100644 index 0000000..38318cc --- /dev/null +++ b/public/images/call.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/circleCall.svg b/public/images/circleCall.svg new file mode 100644 index 0000000..5350d60 --- /dev/null +++ b/public/images/circleCall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/circleMessage.svg b/public/images/circleMessage.svg new file mode 100644 index 0000000..8fd3598 --- /dev/null +++ b/public/images/circleMessage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/circlePlus.svg b/public/images/circlePlus.svg new file mode 100644 index 0000000..071006c --- /dev/null +++ b/public/images/circlePlus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/clearSend.svg b/public/images/clearSend.svg new file mode 100644 index 0000000..25424c2 --- /dev/null +++ b/public/images/clearSend.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/dicord.svg b/public/images/dicord.svg new file mode 100644 index 0000000..67ed1ff --- /dev/null +++ b/public/images/dicord.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/dicord_2.svg b/public/images/dicord_2.svg new file mode 100644 index 0000000..a6f5aa7 --- /dev/null +++ b/public/images/dicord_2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/dicord_3.svg b/public/images/dicord_3.svg new file mode 100644 index 0000000..2e7a9d9 --- /dev/null +++ b/public/images/dicord_3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/discord24.svg b/public/images/discord24.svg new file mode 100644 index 0000000..6b1a3a7 --- /dev/null +++ b/public/images/discord24.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/discord32.svg b/public/images/discord32.svg new file mode 100644 index 0000000..10b126e --- /dev/null +++ b/public/images/discord32.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/discord40.svg b/public/images/discord40.svg new file mode 100644 index 0000000..ee522d5 --- /dev/null +++ b/public/images/discord40.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/discord48.svg b/public/images/discord48.svg new file mode 100644 index 0000000..f881a2b --- /dev/null +++ b/public/images/discord48.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/discord80.svg b/public/images/discord80.svg new file mode 100644 index 0000000..6616917 --- /dev/null +++ b/public/images/discord80.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/discordGreen40.svg b/public/images/discordGreen40.svg new file mode 100644 index 0000000..7a6dcbe --- /dev/null +++ b/public/images/discordGreen40.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/edit.svg b/public/images/edit.svg new file mode 100644 index 0000000..56139d5 --- /dev/null +++ b/public/images/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/friend.svg b/public/images/friend.svg new file mode 100644 index 0000000..b6fd9c7 --- /dev/null +++ b/public/images/friend.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/iPhoneHomeIndicator.svg b/public/images/iPhoneHomeIndicator.svg new file mode 100644 index 0000000..16584bb --- /dev/null +++ b/public/images/iPhoneHomeIndicator.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/internetSignal.svg b/public/images/internetSignal.svg new file mode 100644 index 0000000..a3c8f10 --- /dev/null +++ b/public/images/internetSignal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/leftArrow.svg b/public/images/leftArrow.svg new file mode 100644 index 0000000..7abfda2 --- /dev/null +++ b/public/images/leftArrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/likeHeart.svg b/public/images/likeHeart.svg new file mode 100644 index 0000000..8bdbf81 --- /dev/null +++ b/public/images/likeHeart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/magnifyingGlass.svg b/public/images/magnifyingGlass.svg new file mode 100644 index 0000000..2e92865 --- /dev/null +++ b/public/images/magnifyingGlass.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/messageLarge.svg b/public/images/messageLarge.svg new file mode 100644 index 0000000..1e17a7f --- /dev/null +++ b/public/images/messageLarge.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/messageSmall.svg b/public/images/messageSmall.svg new file mode 100644 index 0000000..63622e1 --- /dev/null +++ b/public/images/messageSmall.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/newFriend.svg b/public/images/newFriend.svg new file mode 100644 index 0000000..e830ab5 --- /dev/null +++ b/public/images/newFriend.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/newMessage.svg b/public/images/newMessage.svg new file mode 100644 index 0000000..08e46cd --- /dev/null +++ b/public/images/newMessage.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/plus.svg b/public/images/plus.svg new file mode 100644 index 0000000..808eaa5 --- /dev/null +++ b/public/images/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/rightarrow.svg b/public/images/rightarrow.svg new file mode 100644 index 0000000..c25f14f --- /dev/null +++ b/public/images/rightarrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/smileEmoji.svg b/public/images/smileEmoji.svg new file mode 100644 index 0000000..f43c401 --- /dev/null +++ b/public/images/smileEmoji.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/staleSend.svg b/public/images/staleSend.svg new file mode 100644 index 0000000..ce4b00d --- /dev/null +++ b/public/images/staleSend.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/video.svg b/public/images/video.svg new file mode 100644 index 0000000..9a8d227 --- /dev/null +++ b/public/images/video.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/whiteSend.svg b/public/images/whiteSend.svg new file mode 100644 index 0000000..ce9496d --- /dev/null +++ b/public/images/whiteSend.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/index.html b/public/index.html index aa069f2..5abcab9 100644 --- a/public/index.html +++ b/public/index.html @@ -10,34 +10,15 @@ content="Web site created using create-react-app" /> - - React App - - +
- 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.tsx b/src/App.tsx index bd79c18..b47d19c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,42 @@ +import { ThemeProvider } from 'styled-components'; +import theme from '@styles/theme'; +import GlobalStyles from '@styles/globalStyles'; +import ChatMain from '@pages/ChatMain'; +import { RecoilRoot } from 'recoil'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import CommonLayout from '@pages/CommonLayout'; +import Friends from '@pages/Friends'; +import Messages from '@pages/Messages'; +import Profile from '@pages/Profile'; +import EmptyChat from '@pages/EmptyChat'; +import NotFound from '@pages/NotFound'; + function App() { - return ( -
-

19기 프론트엔드 파이팅!!! 디자인과 사이좋게 지내요~~~

-
- ); + return ( + + + + + + }> + }> + }> + }> + }> + + }> + }> + } /> + + } + > + + + + + ); } export default App; diff --git a/src/components/fixed/ChatHead/ChatHead.tsx b/src/components/fixed/ChatHead/ChatHead.tsx new file mode 100644 index 0000000..c1b5632 --- /dev/null +++ b/src/components/fixed/ChatHead/ChatHead.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; +import IphoneStatusBar from '@components/fixed/ChatHead/IphoneStatusBar/IphoneStatusBar'; +import ChatHeadNav from '@components/fixed/ChatHead/ChatHeadNav/ChatHeadNav'; + +const StyledChatHead = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 111px; +`; + +export default function ChatHead() { + return ( + + + + + ); +} diff --git a/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNav.tsx b/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNav.tsx new file mode 100644 index 0000000..d0b46fb --- /dev/null +++ b/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNav.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; +import ChatHeadNavLeft from '@components/fixed/ChatHead/ChatHeadNav/ChatHeadNavLeft'; +import ChatHeadNavRight from '@components/fixed/ChatHead/ChatHeadNav/ChatHeadNavRight'; + +export const StyledChatHeadNav = styled.nav` + height: 64px; + flex-grow: 1; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; +`; + +export default function ChatHeadNav() { + return ( + + + + + ); +} diff --git a/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNavLeft.tsx b/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNavLeft.tsx new file mode 100644 index 0000000..29d69ae --- /dev/null +++ b/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNavLeft.tsx @@ -0,0 +1,79 @@ +import styled from 'styled-components'; +import * as ST from '@styles/styledComponents'; +import { useRecoilState } from 'recoil'; +import { userNumberState } from '@context/state/atom'; +import { useNavigate } from 'react-router-dom'; + +export const StyledChatHeadNavLeft = styled.div` + width: 122px; + height: 32px; + display: flex; + justify-content: space-between; + align-items: center; + margin-left: 16px; +`; + +export const StyledChatHeavNavLeftBackImg = styled.img` + width: 24px; + height: 24px; + ${ST.hoverCursor} + margin-right: 8px; +`; + +export const StyledChatHeadNavLeftDiscordImage = styled.img` + width: 32px; + height: 32px; + border-radius: 50%; + ${ST.hoverCursor} + margin-left: 8px; + margin-right: 4px; +`; + +export const StyledUserNameSpan = styled.span` + font-size: ${(props) => props.theme.textStyle.fontSize.h3}; + line-height: ${(props) => props.theme.textStyle.lineHeight.h3}; + font-weight: 600; + ${ST.hoverCursor} + margin-left: 4px; + white-space: nowrap; +`; + +export default function ChatHeadNavLeft() { + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + const navigate = useNavigate(); + + function handleClickChangeUserMode() { + if (userNumber === 1) { + setUserNumber(2); + } else if (userNumber === 2) { + setUserNumber(1); + } + } + + function handleClickArrowBackImage() { + setUserNumber(1); // 뒤로 가면 userNumeber는 원상태로 초기화 + navigate('/messages'); + } + + return ( + + + + {userNumber === 1 ? ( + + 김정민 + + ) : ( + + 김승완 + + )} + + ); +} diff --git a/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNavRight.tsx b/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNavRight.tsx new file mode 100644 index 0000000..971d3d8 --- /dev/null +++ b/src/components/fixed/ChatHead/ChatHeadNav/ChatHeadNavRight.tsx @@ -0,0 +1,32 @@ +import styled from 'styled-components'; +import * as ST from '@styles/styledComponents'; + +const StyledChatHeadNavRight = styled.div` + height: 32px; + width: 80px; + display: flex; + justify-content: space-between; + align-items: center; + margin-right: 16px; +`; + +const StyledChatHeadNavRightCallImg = styled.img` + width: 32px; + height: 32px; + ${ST.hoverCursor} +`; + +const StyledChatHeadNavRightBalloonImg = styled.img` + width: 32px; + height: 32px; + ${ST.hoverCursor} +`; + +export default function ChatHeadNavRight() { + return ( + + + + + ); +} diff --git a/src/components/fixed/ChatHead/ChatHeadNav/EmptyChatHeadNav.tsx b/src/components/fixed/ChatHead/ChatHeadNav/EmptyChatHeadNav.tsx new file mode 100644 index 0000000..91244fa --- /dev/null +++ b/src/components/fixed/ChatHead/ChatHeadNav/EmptyChatHeadNav.tsx @@ -0,0 +1,61 @@ +import { + StyledChatHeadNavLeft, + StyledChatHeavNavLeftBackImg, + StyledChatHeadNavLeftDiscordImage, + StyledUserNameSpan, +} from '@components/fixed/ChatHead/ChatHeadNav/ChatHeadNavLeft'; +import ChatHeadNavRight from '@components/fixed/ChatHead/ChatHeadNav/ChatHeadNavRight'; +import styled from 'styled-components'; +import { userNumberState } from '@context/state/atom'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilState } from 'recoil'; +import { makeUserIdNumber } from '@utils/makeUserIdNumber'; + +const StyledEmptyChatHeadNav = styled.nav` + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +`; + +export default function EmptyChatHeadNav({ + username, +}: { + username: string | undefined; // 공백이 올 수도 있다 +}) { + const navigate = useNavigate(); + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + + function handleClickArrowBackImage() { + setUserNumber(1); + navigate('/messages'); + } + // 상단 이름 부분을 누르면 벌어져야 하는일 --> userNumber가 바뀌어야 한다. 그에 따라서 이름도 바뀌어야 한다 + function handleClickHeadName() { + if (userNumber === 1) { + setUserNumber(makeUserIdNumber(username)); + } else { + setUserNumber(1); + } + } + + return ( + + + + + + {userNumber !== 1 ? '김승완' : username} + + + + + ); +} diff --git a/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusBar.tsx b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusBar.tsx new file mode 100644 index 0000000..274a37f --- /dev/null +++ b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusBar.tsx @@ -0,0 +1,22 @@ +import styled from 'styled-components'; +import IphoneStatusLeftDiv from '@components/fixed/ChatHead/IphoneStatusBar/IphoneStatusLeftDiv'; +import IphoneStatusRightDiv from '@components/fixed/ChatHead/IphoneStatusBar/IphoneStatusRightDiv'; + +const StyledIphoneStatusBar = styled.div` + width: 375px; + height: 47px; + display: flex; + justify-content: space-between; + align-items: center; + column-gap: 195px; + flex-shrink: 0; +`; + +export default function IphoneStatusBar() { + return ( + + + + + ); +} diff --git a/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusLeftDiv.tsx b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusLeftDiv.tsx new file mode 100644 index 0000000..23e91ad --- /dev/null +++ b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusLeftDiv.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; +import { adjustTimeForUserLocation } from '@utils/makeTimeString'; + +const StyledIphoneStatusLeftDiv = styled.div` + width: 54px; + height: 21px; + text-align: center; + padding-top: 5px; + padding-bottom: 3px; + font-size: 17px; + font-weight: 700; + margin-left: 25px; +`; + +export default function IphoneStatusLeftDiv() { + const time = adjustTimeForUserLocation(); + + return ( + {time.slice(11, 16)} + ); +} diff --git a/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusRightDiv.tsx b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusRightDiv.tsx new file mode 100644 index 0000000..ab6d33c --- /dev/null +++ b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusRightDiv.tsx @@ -0,0 +1,22 @@ +import styled from 'styled-components'; +import { + StyledBatteryImage, + StyledMobileSignalImage, + StyledWifiImage, +} from './IphoneStatusbarImage'; + +const StyledIphoneStatusRightDiv = styled.div` + display: flex; + align-items: center; + margin-right: 23.6px; +`; + +export default function IphoneStatusRightDiv() { + return ( + + + + + + ); +} diff --git a/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusbarImage.tsx b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusbarImage.tsx new file mode 100644 index 0000000..0be428c --- /dev/null +++ b/src/components/fixed/ChatHead/IphoneStatusBar/IphoneStatusbarImage.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +export const StyledBatteryImage = styled.img` + margin-left: 7px; +`; + +export const StyledMobileSignalImage = styled.img``; + +export const StyledWifiImage = styled.img` + margin-left: 8px; +`; diff --git a/src/components/fixed/HomeIndicator/HomeIndicator.tsx b/src/components/fixed/HomeIndicator/HomeIndicator.tsx new file mode 100644 index 0000000..1e729c3 --- /dev/null +++ b/src/components/fixed/HomeIndicator/HomeIndicator.tsx @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledHomeIndicator = styled.div` + width: 100%; + height: 34px; + position: absolute; + bottom: 0; + background-color: transparent; +`; + +const StyledHomeIndicatorImage = styled.img` + position: absolute; + top: 21px; + right: 120px; + bottom: 8px; + left: 121px; +`; + +export default function HomeIndicator() { + return ( + + + + ); +} diff --git a/src/components/fixed/SearchHead/SearchHead.tsx b/src/components/fixed/SearchHead/SearchHead.tsx new file mode 100644 index 0000000..ce84ed1 --- /dev/null +++ b/src/components/fixed/SearchHead/SearchHead.tsx @@ -0,0 +1,117 @@ +import styled from 'styled-components'; + +const StyledSearchHead = styled.div` + width: 100%; + height: 114px; + display: flex; + justify-content: center; + align-items: center; +`; + +const StyledSearchInnerContainer = styled.div` + width: 343px; + height: 82px; + display: flex; + flex-direction: column; + justify-content: space-between; + row-gap: 10px; +`; + +const StyledSearchUpper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const StyledNameSpan = styled.p` + height: 30px; + display: flex; + justify-content: center; + align-items: center; + font-size: 20px; + font-weight: 600; + margin-left: 8px; + color: ${(props) => props.theme.color.black}; + white-space: nowrap; +`; + +const StyledNewMessage = styled.div` + background-color: ${(props) => props.theme.color.grayMedium}; + width: 111px; + height: 32px; + border-radius: 20px; + display: flex; + justify-content: center; + align-items: center; + column-gap: 4px; + + &:hover { + cursor: pointer; + } +`; + +const StyledNewMessageSpan = styled.span` + font-family: Pretendard; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; +`; + +const StyledSearchLower = styled.div` + width: 100%; + height: 40px; + position: relative; +`; + +const StyledSearchLowerInput = styled.input` + border: none; + width: 100%; + height: 100%; + background-color: ${(props) => props.theme.color.grayMedium}; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + font-family: Pretendard; + line-height: 19.5px; + color: ${(props) => props.theme.color.grayDark}; + padding: 0 0 0 36px; + outline: none; +`; + +const StyledMagnifyingGlass = styled.img` + position: absolute; + top: 10px; + left: 12px; +`; + +interface searchTypeObj { + searchType: string; +} + +export default function SearchHead({ searchType }: searchTypeObj) { + return ( + + + + + {searchType === 'friends' ? '친구' : '메시지'} + + + {searchType === 'friends' ? ( + This is new friend icon + ) : ( + This is new message icon + )} + 새 메시지 + + + + + + + + + ); +} diff --git a/src/components/non-fixed/ChatBody/ChatBody.tsx b/src/components/non-fixed/ChatBody/ChatBody.tsx new file mode 100644 index 0000000..d8ae2ed --- /dev/null +++ b/src/components/non-fixed/ChatBody/ChatBody.tsx @@ -0,0 +1,132 @@ +import { useRecoilState } from 'recoil'; +import { + messageDataState, + messageDateArrayState, + isMessageLikeButtonClickedState, +} from '@context/state/atom'; +import { useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import OneDateContainer from '@components/non-fixed/ChatBody/OneDateContainer/OneDateContainer'; +import { messageDataObject, voidFunction } from 'types/type'; +import sortByDate from '@utils/sortArrayByDate'; +import useScrollToBottom from '@hooks/useScrollToBottom'; + +const StyledChatBodyContainer = styled.div` + flex-grow: 1; + margin-top: 16px; + margin-right: 16px; + margin-left: 16px; + width: 343px; + overflow-y: scroll; +`; + +export default function ChatBody() { + // 여기에서 json 데이터를 불러와서 날짜별로 쪼갠다. 그리고 구분선, 메시지,메시지, 다시 구분선 메시지 메시지 느낌으로 나눠준다 + const chatBodyContainerRef = useRef(null); + const [messageData, setMessageData] = useRecoilState(messageDataState); + const [messageDateArray, setMessageDateArray] = useRecoilState( + messageDateArrayState + ); + const [isMessageLikeButtonClicked, setIsMessageLikeButtonClicked] = + useRecoilState(isMessageLikeButtonClickedState); + + const [scrollToBottom, setScrollFunction] = useScrollToBottom(); + + const scrollToBottomFunction: voidFunction = function () { + if (chatBodyContainerRef.current) { + chatBodyContainerRef.current.scrollTop = + chatBodyContainerRef.current.scrollHeight; + } + }; + + useEffect(() => { + setScrollFunction(scrollToBottomFunction); + }, []); + + // messageData가 변경된 이후에 dom에 반영되고 그 다음에 scroll이 내려가야 새로 생긴 요소까지 반영 + useEffect(() => { + // 좋아요 버튼이 눌린 상태면 하트 UI만 만들어주고 다시 false로 만들어주고 끝내야 다음 상태가 정상적으로 반영 + setIsMessageLikeButtonClicked(false); + if (isMessageLikeButtonClicked === true) { + return; + } + scrollToBottom(); + }, [messageData]); + + // 처음 chatBody 컴포넌트가 DOM에 마운트 되면 json 파일로부터 정보를 가져온다. + useEffect(() => { + async function loadMessageData() { + try { + const tmpDateArray: string[] = []; + const tmpMessageDataObject: messageDataObject = {}; // 상태로도 사용하고 로컬 스토리지에도 동기화를 해줄 객체 + + const response = await fetch('/Dummy/Dummy.json'); + const messageJsonData = await response.json(); + + for (const messageData of messageJsonData) { + const { content, createdAt, from, like } = messageData; + const slicedCreatedDate: string = createdAt.slice(0, 10); + if (tmpMessageDataObject[slicedCreatedDate] === undefined) { + tmpMessageDataObject[slicedCreatedDate] = []; + } + + tmpMessageDataObject[slicedCreatedDate].push({ + content: content, + createdAt: createdAt, + createdDate: slicedCreatedDate, + from: from, + like: like, + }); + + if (!tmpDateArray.includes(slicedCreatedDate)) { + tmpDateArray.push(slicedCreatedDate); + } + } + // 기존의 날짜 배열을 오름차순으로 정렬 + tmpDateArray.sort(sortByDate); + localStorage.setItem( + 'chatMessageData', + JSON.stringify(tmpMessageDataObject) + ); + localStorage.setItem( + 'chatMessageDateArray', + JSON.stringify(tmpDateArray) + ); + setMessageData(tmpMessageDataObject); + setMessageDateArray(tmpDateArray); + } catch (error) { + console.log('error is : ', error); + } + } + + // 기존의 로컬 스토리지에 아무 정보도 없다면 json 파일의 내용을 상태로 만들고 로컬 스토리지에도 반영 + // 하지만 이미 있다면 해당 내용을 가져와서 상태로 만든다 + if ( + localStorage.getItem('chatMessageData') === null && + localStorage.getItem('chatMessageDateArray') === null + ) { + loadMessageData(); + } else if ( + localStorage.getItem('chatMessageData') !== null && + localStorage.getItem('chatMessageDateArray') !== null + ) { + const lstrgChatMessageData = JSON.parse( + localStorage.getItem('chatMessageData') as string + ); + const lstrgChatMessageDateArray = JSON.parse( + localStorage.getItem('chatMessageDateArray') as string + ); + + setMessageData(lstrgChatMessageData); + setMessageDateArray(lstrgChatMessageDateArray); + } + }, []); + + return ( + + {messageDateArray.map((messageDate) => { + return ; + })} + + ); +} diff --git a/src/components/non-fixed/ChatBody/ChatLog/ChatLog.tsx b/src/components/non-fixed/ChatBody/ChatLog/ChatLog.tsx new file mode 100644 index 0000000..813456a --- /dev/null +++ b/src/components/non-fixed/ChatBody/ChatLog/ChatLog.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import ChatLogLeft from '@components/non-fixed/ChatBody/ChatLog/ChatLogLeft/ChatLogLeft'; +import ChatLogRight from '@components/non-fixed/ChatBody/ChatLog/ChatLogRight/ChatLogRight'; +import { chatBodyDivElementGap } from '@styles/styledComponents'; + +const StyledChatLogContainer = styled.div` + width: 100%; + height: fit-content; + display: flex; + column-gap: 8px; + ${chatBodyDivElementGap} +`; + +export default function ChatLog({ + isEqual, + from, + createdAt, + content, + like, +}: { + isEqual: boolean; + from: number; + createdAt: string; + content: string; + like: boolean; +}) { + return ( + + + + + ); +} diff --git a/src/components/non-fixed/ChatBody/ChatLog/ChatLogLeft/ChatLogLeft.tsx b/src/components/non-fixed/ChatBody/ChatLog/ChatLogLeft/ChatLogLeft.tsx new file mode 100644 index 0000000..1a6051f --- /dev/null +++ b/src/components/non-fixed/ChatBody/ChatLog/ChatLogLeft/ChatLogLeft.tsx @@ -0,0 +1,23 @@ +import styled from 'styled-components'; +import { isMessageOwnerEqualsWithModeProps } from 'types/type'; + +const StyledChatLogLeft = styled.div` + width: auto; + height: 100%; +`; + +const StyledChatDiscordLogo = styled.img``; + +export default function ChatLogLeft({ + isEqual, +}: isMessageOwnerEqualsWithModeProps) { + return ( + + {isEqual === true ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/non-fixed/ChatBody/ChatLog/ChatLogRight/ChatLogRight.tsx b/src/components/non-fixed/ChatBody/ChatLog/ChatLogRight/ChatLogRight.tsx new file mode 100644 index 0000000..dce7c6a --- /dev/null +++ b/src/components/non-fixed/ChatBody/ChatLog/ChatLogRight/ChatLogRight.tsx @@ -0,0 +1,115 @@ +import styled from 'styled-components'; +import { useRecoilState } from 'recoil'; +import { + messageDataState, + userNumberState, + isMessageLikeButtonClickedState, +} from '@context/state/atom'; + +const StyledChatLogRightContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const StyledNameAndMessageContainer = styled.div` + display: flex; + height: 20px; + justify-content: flex-start; + column-gap: 4px; +`; + +const StyledNameSpan = styled.div` + padding-top: 4px; + padding-bottom: 1px; + font-size: 13px; + font-weight: 500; + height: 100%; + color: ${(props) => props.theme.color.grayDark}; +`; + +const StyledTimeSpan = styled.div` + padding-top: 4px; + padding-bottom: 1px; + display: flex; + align-items: center; + flex-grow: 1; + font-size: 10px; + font-weight: 500; + color: ${(props) => props.theme.color.grayDark}; +`; + +const StyledMessageContentContainer = styled.div` + height: fit-content; + flex-grow: 1; + font-size: ${(props) => props.theme.textStyle.fontSize.body2}; + font-weight: 400; + color: ${(props) => props.theme.color.black}; + line-height: 22.5px; +`; + +const StyledLikeHeartDiv = styled.div` + width: 100%; + height: 22px; + display: flex; + justify-content: flex-start; + padding-top: 2px; +`; + +const StyledLikeHeartImage = styled.img``; +export default function ChatLogRight({ + isEqual, + from, + createdAt, + content, + like, +}: { + isEqual: boolean; + from: number; + createdAt: string; + content: string; + like: boolean; +}) { + const [messageData, setMessageData] = useRecoilState(messageDataState); + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + const [isMessageLikeButtonClicked, setIsMessageLikeButtonClicked] = + useRecoilState(isMessageLikeButtonClickedState); + + const createdHourMinute = createdAt.slice(11, 16); + const createdDate = createdAt.slice(0, 10); + const deepCopiedMessageData = JSON.parse(JSON.stringify(messageData)); + + function handleDoubleClickMessage() { + // 자신과 달라야 한다. + if (from === userNumber) return; + + const prevSpecifiedDateArray = deepCopiedMessageData[createdDate]; + for (const data of prevSpecifiedDateArray) { + if (data['createdAt'] === createdAt) { + data['like'] = !data['like']; + } + } + setMessageData(deepCopiedMessageData); + localStorage.setItem( + 'chatMessageData', + JSON.stringify(deepCopiedMessageData) + ); + // 메시지 버튼이 눌렸는지에 관한 상태를 true로 만들어주고 chatBody에서 useEffect에서 조건부로 검사함 + setIsMessageLikeButtonClicked(true); + } + + return ( + + + {from === 2 ? '김정민' : '김승완'} + {createdHourMinute} + + {content} + {isEqual === false && like === true && ( + + + + )} + + ); +} diff --git a/src/components/non-fixed/ChatBody/DateDivider/DateDivider.tsx b/src/components/non-fixed/ChatBody/DateDivider/DateDivider.tsx new file mode 100644 index 0000000..acd4170 --- /dev/null +++ b/src/components/non-fixed/ChatBody/DateDivider/DateDivider.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import { dateStringProps } from 'types/type'; +import { dateBeforeAfter } from '@styles/styledComponents'; +import { chatBodyDivElementGap } from '@styles/styledComponents'; + +const StyledDateDividerContainer = styled.div` + width: 100%; + height: 20px; + display: flex; + justify-content: center; + align-items: center; + position: relative; + ${chatBodyDivElementGap} +`; + +const StyledDateDivider = styled.div` + &::before { + ${dateBeforeAfter} + left: -8px; + } + + &::after { + ${dateBeforeAfter} + right: -8px; + } + font-size: 13px; + font-weight: 500; + line-height: 19.5px; +`; + +export default function DateDivider({ dateString }: dateStringProps) { + const [year, month, day] = dateString.split('-'); + + return ( + + {`${year}년 ${month}월 ${day}일`} + + ); +} diff --git a/src/components/non-fixed/ChatBody/OneDateContainer/OneDateContainer.tsx b/src/components/non-fixed/ChatBody/OneDateContainer/OneDateContainer.tsx new file mode 100644 index 0000000..8d3cdf8 --- /dev/null +++ b/src/components/non-fixed/ChatBody/OneDateContainer/OneDateContainer.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import { useRecoilState } from 'recoil'; +import DateDivider from '@components/non-fixed/ChatBody/DateDivider/DateDivider'; +import { userNumberState, messageDataState } from '@context/state/atom'; +import ChatLog from '@components/non-fixed/ChatBody/ChatLog/ChatLog'; + +const StyledOneDateContainer = styled.div` + width: 100%; + height: auto; +`; + +export default function OneDateContainer({ + messageDate, +}: { + messageDate: string; +}) { + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + const [messageData, setMessageData] = useRecoilState(messageDataState); + + const oneDateMessagData = messageData[messageDate]; + + return ( + + + {oneDateMessagData.map((data) => { + return ( + + ); + })} + + ); +} diff --git a/src/components/non-fixed/ChatInput/ChatInput.tsx b/src/components/non-fixed/ChatInput/ChatInput.tsx new file mode 100644 index 0000000..f8ebe3c --- /dev/null +++ b/src/components/non-fixed/ChatInput/ChatInput.tsx @@ -0,0 +1,18 @@ +import styled from 'styled-components'; +import ChatInputForm from '@components/non-fixed/ChatInput/ChatInputForm'; + +const StyledChatInputContainer = styled.div` + width: 375px; + min-height: 90px; + padding-bottom: 34px; + background-color: ${(props) => props.theme.color.white}; + opacity: 0.95; +`; + +export default function ChatInput() { + return ( + + + + ); +} diff --git a/src/components/non-fixed/ChatInput/ChatInputForm.tsx b/src/components/non-fixed/ChatInput/ChatInputForm.tsx new file mode 100644 index 0000000..ac3695f --- /dev/null +++ b/src/components/non-fixed/ChatInput/ChatInputForm.tsx @@ -0,0 +1,189 @@ +import styled from 'styled-components'; +import * as ST from '@styles/styledComponents'; +import { useRecoilState } from 'recoil'; +import { useRef } from 'react'; +import { + isInputBoxFocusedState, + messageDataState, + messageDateArrayState, + userNumberState, +} from '@context/state/atom'; +import { adjustTimeForUserLocation } from '@utils/makeTimeString'; +import sortByDate from '@utils/sortArrayByDate'; +import { useParams } from 'react-router-dom'; + +const StyledChatInputForm = styled.form` + width: 100%; + height: 56px; + display: flex; + align-items: center; + column-gap: 8px; + padding: 0 16px; +`; + +const StyledPlusButton = styled.img` + ${ST.hoverCursor}; +`; + +const StyledInputBoxContainer = styled.div` + height: 40px; + flex-grow: 1; + flex-shrink: 0; + position: relative; +`; + +const StyledInputBox = styled.input` + outline: none; + border: none; + width: 100%; + height: 100%; + text-indent: 16px; + font-size: 15px; + font-weight: 400; + font-family: Pretendard; + background-color: ${(props) => props.theme.color.grayMedium}; + border-radius: 20px; + padding: 0; + padding-right: 40px; // 텍스트가 스마일 표시를 넘어가지 않게 +`; + +const StyledSmilingIcon = styled.img` + position: absolute; + right: 12px; + top: 8px; + bottom: 8px; +`; + +const StyledStaleSendIcon = styled.img` + /* ${ST.hoverCursor}; */ +`; + +const StyledSubmitButton = styled.button` + border: none; + background: none; + padding: 0; + margin: 0; +`; + +const StyledClearSendIcon = styled.img` + ${ST.hoverCursor}; +`; + +export default function ChatInputForm() { + const { username } = useParams(); // input form은 chat에서도 나타나고 chat/다른사용자에서도 나타남 + + const inputRef = useRef(null); + const [isInputBoxFocused, setIsInputBoxFocused] = useRecoilState( + isInputBoxFocusedState + ); + const [messageData, setMessageData] = useRecoilState(messageDataState); + const [messageDateArray, setMessageDateArray] = useRecoilState( + messageDateArrayState + ); + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + + function handleMakeIsInputfocusedFalse() { + // 사용자가 인풋에 텍스트를 치다가 다른 화면을 눌러 나왔을때, onBlur() 트리거 함수가 작동했을 때 false가 되면 안됨 + if (isInputBoxFocused && inputRef.current?.value !== '') { + return; + } + setIsInputBoxFocused(false); + } + + function handleMakeIsInputfocusedTrue() { + if (inputRef.current?.value === '') return; + setIsInputBoxFocused(true); + } + + function handleUserTypeInput() { + // 인풋에 타이핑을 하고 있다가 다 지워버려도 해당 함수는 트리거 됨. 다 비어버리면 false로 만들고 함수 종료 + if (inputRef.current?.value === '') { + setIsInputBoxFocused(false); + return; + } + setIsInputBoxFocused(true); + } + + function handleSubmitForm(ev: any) { + if (isInputBoxFocused === false) { + ev.preventDefault(); + } else if (isInputBoxFocused === true) { + if (inputRef.current?.value === '') return; + ev.preventDefault(); + + const createdAt = adjustTimeForUserLocation(); + const createdDate = createdAt.slice(0, 10); + const content = inputRef.current?.value as string; + const from = userNumber; + const like = false; + + const newMessageData = { + content: content, + createdAt: createdAt, + createdDate: createdDate, + from: from, + like: like, + }; + + const tmpMessageData = Object.assign({}, messageData); + + const tmpMessageDateArray = [...messageDateArray]; + + if (tmpMessageData[createdDate] === undefined) { + tmpMessageData[createdDate] = []; + tmpMessageData[createdDate].push(newMessageData); + } else { + // 기존의 배열을 구조분해하여 다른 메모리 값을 가진 배열로 받음 + const prevDateArray = [...tmpMessageData[createdDate]]; + prevDateArray.push(newMessageData); + tmpMessageData[createdDate] = prevDateArray; + } + + if (!tmpMessageDateArray.includes(createdDate)) { + tmpMessageDateArray.push(createdDate); + tmpMessageDateArray.sort(sortByDate); + } + setMessageData(tmpMessageData); + setMessageDateArray(tmpMessageDateArray); + const usernameVariable = username === undefined ? '' : username; // chat/뭐시기 url에서도 inputform을 낭낭하게 써먹기 위한 설정 + localStorage.setItem( + `chatMessageData${usernameVariable}`, + JSON.stringify(tmpMessageData) + ); + localStorage.setItem( + `chatMessageDateArray${usernameVariable}`, + JSON.stringify(tmpMessageDateArray) + ); + if (inputRef.current !== null) { + inputRef.current.value = ''; + } + setIsInputBoxFocused(false); + inputRef.current?.blur(); + } + } + + return ( + + {isInputBoxFocused === false && ( + + )} + + + + + + {isInputBoxFocused === false ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/EmptyChatBody.tsx b/src/components/non-fixed/EmptyChatBody/EmptyChatBody.tsx new file mode 100644 index 0000000..2edb47e --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/EmptyChatBody.tsx @@ -0,0 +1,114 @@ +import styled from 'styled-components'; +import ProfileGroupUI from '@components/non-fixed/EmptyChatBody/ProfileGroupUI/ProfileGroupUI'; +import EmptyOneDateContainer from './EmptyOneDateContainer/EmptyOneDateContainer'; +import { useEffect, useRef } from 'react'; +import { voidFunction } from 'types/type'; +import { useRecoilState } from 'recoil'; +import { + messageDataState, + messageDateArrayState, + isMessageLikeButtonClickedState, +} from '@context/state/atom'; +import ldsh from 'lodash'; // 객체의 깊은 비교를 위한 라이브러리에 해당 +import useScrollToBottom from '@hooks/useScrollToBottom'; + +const StyledEmptyChatBody = styled.div` + flex-grow: 1; + margin-top: 16px; + margin-right: 16px; + margin-left: 16px; + width: 343px; + overflow-y: scroll; +`; + +const StyledProfileGroupUIContainer = styled.div` + width: 100%; + height: fit-content; + + padding-top: 16px; + display: flex; + justify-content: center; +`; + +export default function EmptyChatBody({ + username, +}: { + username: string | undefined; +}) { + const emptyChatBodyContainerRef = useRef(null); + const [messageData, setMessageData] = useRecoilState(messageDataState); + const [messageDateArray, setMessageDateArray] = useRecoilState( + messageDateArrayState + ); + + const [isMessageLikeButtonClicked, setIsMessageLikeButtonClicked] = + useRecoilState(isMessageLikeButtonClickedState); + + const [scrollToBottom, setScrollFunction] = useScrollToBottom(); + + const scrollToBottomFunction: voidFunction = function () { + if (emptyChatBodyContainerRef.current) { + emptyChatBodyContainerRef.current.scrollTop = + emptyChatBodyContainerRef.current.scrollHeight; + } + }; + + useEffect(() => { + setScrollFunction(scrollToBottomFunction); + }, []); + + useEffect(() => { + // 좋아요 버튼이 눌린 상태면 하트 UI만 만들어주고 다시 false로 만들어주고 끝내야 다음 상태가 정상적으로 반영 + setIsMessageLikeButtonClicked(false); + if (isMessageLikeButtonClicked === true) { + return; + } + scrollToBottom(); + }, [messageData]); + + // 기존의 데이터가 없으면 로고를 보여주고, 아니면 이에 맞는 메시지 데이터들을 보여주면 된다 + + useEffect(() => { + if ( + localStorage.getItem(`chatMessageData${username}`) === null && + localStorage.getItem(`chatMessageDateArray${username}`) === null + ) { + setMessageData({}); + setMessageDateArray([]); + } else if ( + // 데이터가 있는 경우 -> 상태를 변경시키고 이를 이용하여 리렌더링 진행 + localStorage.getItem(`chatMessageData${username}`) !== null && + localStorage.getItem(`chatMessageDateArray${username}`) !== null + ) { + const lstrgChatMessageData = JSON.parse( + localStorage.getItem(`chatMessageData${username}`) as string + ); + const lstrgChatMessageDateArray = JSON.parse( + localStorage.getItem(`chatMessageDateArray${username}`) as string + ); + + setMessageData(lstrgChatMessageData); + setMessageDateArray(lstrgChatMessageDateArray); + } + }, []); + + return ( + + {ldsh.isEqual(messageData, {}) && messageDateArray.length === 0 && ( + + + + )} + {!ldsh.isEqual(messageData, {}) && + !(messageDateArray.length === 0) && + messageDateArray.map((messageDate) => { + return ( + + ); + })} + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLog.tsx b/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLog.tsx new file mode 100644 index 0000000..d0f58f9 --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLog.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import EmptyChatLogLeft from '@components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogLeft/EmptyChatLogLeft'; +import EmptyChatLogRight from '@components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogRight/EmptyChatLogRight'; +import { chatBodyDivElementGap } from '@styles/styledComponents'; + +const StyledChatLogContainer = styled.div` + width: 100%; + height: fit-content; + display: flex; + column-gap: 8px; + ${chatBodyDivElementGap} +`; + +export default function EmptyChatLog({ + isEqual, + from, + createdAt, + content, + like, +}: { + isEqual: boolean; + from: number; + createdAt: string; + content: string; + like: boolean; +}) { + return ( + + + + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogLeft/EmptyChatLogLeft.tsx b/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogLeft/EmptyChatLogLeft.tsx new file mode 100644 index 0000000..9ef1477 --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogLeft/EmptyChatLogLeft.tsx @@ -0,0 +1,23 @@ +import styled from 'styled-components'; +import { isMessageOwnerEqualsWithModeProps } from 'types/type'; + +const StyledChatLogLeft = styled.div` + width: auto; + height: 100%; +`; + +const StyledChatDiscordLogo = styled.img``; + +export default function EmptyChatLogLeft({ + isEqual, +}: isMessageOwnerEqualsWithModeProps) { + return ( + + {isEqual === true ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogRight/EmptyChatLogRight.tsx b/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogRight/EmptyChatLogRight.tsx new file mode 100644 index 0000000..954784d --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLogRight/EmptyChatLogRight.tsx @@ -0,0 +1,117 @@ +import styled from 'styled-components'; +import { useRecoilState } from 'recoil'; +import { + messageDataState, + userNumberState, + isMessageLikeButtonClickedState, +} from '@context/state/atom'; +import { useParams } from 'react-router-dom'; + +const StyledChatLogRightContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const StyledNameAndMessageContainer = styled.div` + display: flex; + height: 20px; + justify-content: flex-start; + column-gap: 4px; +`; + +const StyledNameSpan = styled.div` + padding-top: 4px; + padding-bottom: 1px; + font-size: 13px; + font-weight: 500; + height: 100%; + color: ${(props) => props.theme.color.grayDark}; +`; + +const StyledTimeSpan = styled.div` + padding-top: 4px; + padding-bottom: 1px; + display: flex; + align-items: center; + flex-grow: 1; + font-size: 10px; + font-weight: 500; + color: ${(props) => props.theme.color.grayDark}; +`; + +const StyledMessageContentContainer = styled.div` + height: fit-content; + flex-grow: 1; + font-size: ${(props) => props.theme.textStyle.fontSize.body2}; + font-weight: 400; + color: ${(props) => props.theme.color.black}; + line-height: 22.5px; +`; + +const StyledLikeHeartDiv = styled.div` + width: 100%; + height: 22px; + display: flex; + justify-content: flex-start; + padding-top: 2px; +`; + +const StyledLikeHeartImage = styled.img``; +export default function EmptytChatLogRight({ + isEqual, + from, + createdAt, + content, + like, +}: { + isEqual: boolean; + from: number; + createdAt: string; + content: string; + like: boolean; +}) { + const [messageData, setMessageData] = useRecoilState(messageDataState); + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + const [isMessageLikeButtonClicked, setIsMessageLikeButtonClicked] = + useRecoilState(isMessageLikeButtonClickedState); + const { username } = useParams(); + + const createdHourMinute = createdAt.slice(11, 16); + const createdDate = createdAt.slice(0, 10); + const deepCopiedMessageData = JSON.parse(JSON.stringify(messageData)); + + function handleDoubleClickMessage() { + // 자신과 달라야 한다. + if (from === userNumber) return; + + const prevSpecifiedDateArray = deepCopiedMessageData[createdDate]; + for (const data of prevSpecifiedDateArray) { + if (data['createdAt'] === createdAt) { + data['like'] = !data['like']; + } + } + setMessageData(deepCopiedMessageData); + localStorage.setItem( + `chatMessageData${username}`, + JSON.stringify(deepCopiedMessageData) + ); + // 메시지 버튼이 눌렸는지에 관한 상태를 true로 만들어주고 chatBody에서 useEffect에서 조건부로 검사함 + setIsMessageLikeButtonClicked(true); + } + + return ( + + + {from !== 1 ? `${username}` : '김승완'} + {createdHourMinute} + + {content} + {isEqual === false && like === true && ( + + + + )} + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/EmptyDateDIvider/EmptyDateDivider.tsx b/src/components/non-fixed/EmptyChatBody/EmptyDateDIvider/EmptyDateDivider.tsx new file mode 100644 index 0000000..787e45f --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/EmptyDateDIvider/EmptyDateDivider.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import { dateStringProps } from 'types/type'; +import { dateBeforeAfter } from '@styles/styledComponents'; +import { chatBodyDivElementGap } from '@styles/styledComponents'; + +const StyledDateDividerContainer = styled.div` + width: 100%; + height: 20px; + display: flex; + justify-content: center; + align-items: center; + position: relative; + ${chatBodyDivElementGap} +`; + +const StyledDateDivider = styled.div` + &::before { + ${dateBeforeAfter} + left: -8px; + } + + &::after { + ${dateBeforeAfter} + right: -8px; + } + font-size: 13px; + font-weight: 500; + line-height: 19.5px; +`; + +export default function EmptyDateDivider({ dateString }: dateStringProps) { + const [year, month, day] = dateString.split('-'); + + return ( + + {`${year}년 ${month}월 ${day}일`} + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/EmptyOneDateContainer/EmptyOneDateContainer.tsx b/src/components/non-fixed/EmptyChatBody/EmptyOneDateContainer/EmptyOneDateContainer.tsx new file mode 100644 index 0000000..30b5750 --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/EmptyOneDateContainer/EmptyOneDateContainer.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import { useRecoilState } from 'recoil'; +import EmptyDateDivider from '@components/non-fixed/EmptyChatBody/EmptyDateDIvider/EmptyDateDivider'; +import { userNumberState, messageDataState } from '@context/state/atom'; +import EmptyChatLog from '@components/non-fixed/EmptyChatBody/EmptyChatLog/EmptyChatLog'; + +const StyledOneDateContainer = styled.div` + width: 100%; + height: auto; +`; + +export default function EmptyOneDateContainer({ + messageDate, +}: { + messageDate: string; +}) { + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + const [messageData, setMessageData] = useRecoilState(messageDataState); + + const oneDateMessagData = messageData[messageDate]; + + return ( + + + {oneDateMessagData.map((data) => { + return ( + + ); + })} + + ); +} diff --git a/src/components/non-fixed/EmptyChatBody/ProfileGroupUI/ProfileGroupUI.tsx b/src/components/non-fixed/EmptyChatBody/ProfileGroupUI/ProfileGroupUI.tsx new file mode 100644 index 0000000..a73c5d3 --- /dev/null +++ b/src/components/non-fixed/EmptyChatBody/ProfileGroupUI/ProfileGroupUI.tsx @@ -0,0 +1,74 @@ +import styled from 'styled-components'; + +const StyledProfileGroupUI = styled.div` + width: 100%; + height: 187px; + + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + row-gap: 8px; +`; + +const StyledEightyDiscordImage = styled.img` + width: 80px; + height: 80px; +`; + +const StyledNameComponent = styled.div` + width: 100%; + height: 68px; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + row-gap: 4px; +`; + +const StyledBigName = styled.p` + width: 100%; + height: 41px; + font-size: ${(props) => props.theme.textStyle.fontSize.h1}; + font-family: Pretendard; + line-height: ${(props) => props.theme.textStyle.lineHeight.h1}; + color: ${(props) => props.theme.color.black}; + text-align: center; +`; + +const StyledSmallName = styled.div` + width: 100%; + height: 23px; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + font-family: Pretendard; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; + color: ${(props) => props.theme.color.grayDark}; + text-align: center; +`; + +const StyledGuideDiv = styled.div` + width: 100%; + height: 23px; + font-size: ${(props) => props.theme.textStyle.fontSize.body2}; + font-family: Pretendard; + line-height: ${(props) => props.theme.textStyle.lineHeight.body2}; + color: ${(props) => props.theme.color.grayDark}; + text-align: center; +`; + +export default function ProfileGroupUI({ + username, +}: { + username: string | undefined; +}) { + return ( + + + + {username} + {`${username}_ceos`} + + {username}과 대화를 시작해보세요 + + ); +} diff --git a/src/components/non-fixed/FriendsBody/FriendsBody.tsx b/src/components/non-fixed/FriendsBody/FriendsBody.tsx new file mode 100644 index 0000000..48a880a --- /dev/null +++ b/src/components/non-fixed/FriendsBody/FriendsBody.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; +import FriendsSearchHead from '@components/non-fixed/FriendsBody/FriendsSearchHead/FriendsSearchHead'; +import FriendsList from '@components/non-fixed/FriendsBody/FriendsList/FriendsList'; + +const StyledFriendsBody = styled.section` + flex-grow: 1; + overflow-y: scroll; + display: flex; + flex-direction: column; +`; + +export default function FriendsBody() { + return ( + + + + + ); +} diff --git a/src/components/non-fixed/FriendsBody/FriendsList/FriendsList.tsx b/src/components/non-fixed/FriendsBody/FriendsList/FriendsList.tsx new file mode 100644 index 0000000..5f01a8f --- /dev/null +++ b/src/components/non-fixed/FriendsBody/FriendsList/FriendsList.tsx @@ -0,0 +1,32 @@ +import styled from 'styled-components'; +import FriendsListItem from '@components/non-fixed/FriendsBody/FriendsList/FriendsListItem'; + +const StyledFriendsList = styled.div` + margin: 0 16px; + height: fit-content; +`; + +const tmpFriendsList = [ + 'discord_redesign1', + '김정민', + 'discord_redesign2', + '김승완', + 'discord_redesign3', + 'CEOS', +]; + +export default function FriendsList() { + // 임시 UI를 위한 텍스트 집합 + + return ( + + {tmpFriendsList.map((friendsName, index) => ( + + ))} + + ); +} diff --git a/src/components/non-fixed/FriendsBody/FriendsList/FriendsListItem.tsx b/src/components/non-fixed/FriendsBody/FriendsList/FriendsListItem.tsx new file mode 100644 index 0000000..9500aa9 --- /dev/null +++ b/src/components/non-fixed/FriendsBody/FriendsList/FriendsListItem.tsx @@ -0,0 +1,54 @@ +import styled from 'styled-components'; + +const StyledFriendsListItem = styled.div` + width: 100%; + height: 64px; + display: flex; + justify-content: space-evenly; + align-items: center; + column-gap: 16px; +`; + +const StyledLogoImage = styled.img` + border-radius: 50%; + width: 32px; + height: 32px; +`; + +const StyledSpan = styled.span` + width: 199px; + height: 23px; + font-family: Pretendard; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; + font-weight: 500; + color: ${(props) => props.theme.color.black}; +`; + +const StyledImage = styled.img` + &:hover { + cursor: pointer; + } +`; + +interface friendsListItemObj { + order: number; + friendName: string; +} +export default function FriendsListItem({ + order, + friendName, +}: friendsListItemObj) { + return ( + + {order % 2 === 0 ? ( + + ) : ( + + )} + {friendName} + + + + ); +} diff --git a/src/components/non-fixed/FriendsBody/FriendsSearchHead/FriendsSearchHead.tsx b/src/components/non-fixed/FriendsBody/FriendsSearchHead/FriendsSearchHead.tsx new file mode 100644 index 0000000..bb5c37a --- /dev/null +++ b/src/components/non-fixed/FriendsBody/FriendsSearchHead/FriendsSearchHead.tsx @@ -0,0 +1,5 @@ +import SearchHead from '@components/fixed/SearchHead/SearchHead'; + +export default function FriendsSearchHead() { + return ; +} diff --git a/src/components/non-fixed/MessageBody/MessageBody.tsx b/src/components/non-fixed/MessageBody/MessageBody.tsx new file mode 100644 index 0000000..7a4f0cb --- /dev/null +++ b/src/components/non-fixed/MessageBody/MessageBody.tsx @@ -0,0 +1,17 @@ +import styled from 'styled-components'; +import MessageSearchHead from '@components/non-fixed/MessageBody/MessageSearchHead/MessageSearchHead'; +import MessageListContainer from '@components/non-fixed/MessageBody/MessageList/MessageListContainer'; + +const StyledMessageBody = styled.section` + flex-grow: 1; + overflow-y: scroll; +`; + +export default function MessageBody() { + return ( + + + + + ); +} diff --git a/src/components/non-fixed/MessageBody/MessageList/MessageListContainer.tsx b/src/components/non-fixed/MessageBody/MessageList/MessageListContainer.tsx new file mode 100644 index 0000000..c691082 --- /dev/null +++ b/src/components/non-fixed/MessageBody/MessageList/MessageListContainer.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components'; +import MessageListItem from '@components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItem'; +import { useNavigate } from 'react-router-dom'; + +const StyledMessageListContainer = styled.div` + margin: 0 16px; + height: fit-content; +`; + +export default function MessageListContainer() { + const navigate = useNavigate(); + + function handleClickMessageListItem(path: string) { + navigate(`/chat/${path}`); + } + + return ( + + + + + + + ); +} diff --git a/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItem.tsx b/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItem.tsx new file mode 100644 index 0000000..aa7dd04 --- /dev/null +++ b/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItem.tsx @@ -0,0 +1,48 @@ +import styled from 'styled-components'; +import MessageListItemLeft from '@components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemLeft'; +import MessageListItemRight from '@components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemRight'; + +const StyledMessageListItem = styled.div` + width: 100%; + height: 80px; + display: flex; + justify-content: space-between; + align-items: center; + column-gap: 8px; +`; + +interface navigateFunction { + (pathname: string): void; +} + +interface messageListItemType { + discordLogoColor: string; + name: string; + $ifBlueSignal: boolean; + content: string; + dateString: string; + navigateToChatFunc: navigateFunction; + path: string; +} + +export default function MessageListItem({ + discordLogoColor, + name, + $ifBlueSignal, + content, + dateString, + navigateToChatFunc, + path, +}: messageListItemType) { + return ( + navigateToChatFunc(path)}> + + + + ); +} diff --git a/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemLeft.tsx b/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemLeft.tsx new file mode 100644 index 0000000..502ac12 --- /dev/null +++ b/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemLeft.tsx @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +const StyledMessageListItemLeftContainer = styled.div` + width: 48px; + height: 48px; +`; + +const StyledMessagListItemLeftImage = styled.img` + width: 48px; + height: 48px; + border-radius: 50%; +`; + +export default function MessageListItemLeft({ + discordLogoColor, +}: { + discordLogoColor: string; +}) { + return ( + + {discordLogoColor === 'green' && ( + + )} + + {discordLogoColor === 'purple' && ( + + )} + + ); +} diff --git a/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemRight.tsx b/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemRight.tsx new file mode 100644 index 0000000..4de47d4 --- /dev/null +++ b/src/components/non-fixed/MessageBody/MessageList/MessageListItem/MessageListItemRight.tsx @@ -0,0 +1,82 @@ +import styled from 'styled-components'; + +const StyledMessageListItemRight = styled.div` + display: flex; + flex-direction: column; + width: 287px; + height: 46px; + &:hover { + cursor: pointer; + } +`; + +interface messageListItemRightInnerType { + $ifBlueSignal: boolean; +} + +const StyledMessageListItemRightInner = styled.div` + flex-grow: 1; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const StyledNameSpan = styled.span` + font-family: Pretendard; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; + color: ${(props) => props.theme.color.grayDark}; + font-weight: 500; +`; + +const StyledContentdiv = styled.div` + width: 229px; + height: 22.5px; + font-family: Pretendard; + font-weight: 400; + font-size: ${(props) => props.theme.textStyle.fontSize.body2}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body2}; + color: ${(props) => props.theme.color.black}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const StyledDateString = styled.div` + font-family: Pretendard; + font-weight: 500; + font-size: 10px; + line-height: 15px; + color: ${(props) => props.theme.color.grayDark}; +`; + +interface messageListItemRightType { + name: string; + $ifBlueSignal: boolean; + content: string; + dateString: string; +} + +const StyledBlueSignalImage = styled.img``; + +export default function MessageListItemRight({ + name, + $ifBlueSignal, + content, + dateString, +}: messageListItemRightType) { + return ( + + + {name} + {$ifBlueSignal === true ? ( + + ) : null} + + + {content} + {dateString} + + + ); +} diff --git a/src/components/non-fixed/MessageBody/MessageSearchHead/MessageSearchHead.tsx b/src/components/non-fixed/MessageBody/MessageSearchHead/MessageSearchHead.tsx new file mode 100644 index 0000000..8847654 --- /dev/null +++ b/src/components/non-fixed/MessageBody/MessageSearchHead/MessageSearchHead.tsx @@ -0,0 +1,5 @@ +import SearchHead from '@components/fixed/SearchHead/SearchHead'; + +export default function MessageSearchHead() { + return ; +} diff --git a/src/components/non-fixed/ProfileBody/ProfileBody.tsx b/src/components/non-fixed/ProfileBody/ProfileBody.tsx new file mode 100644 index 0000000..c696c17 --- /dev/null +++ b/src/components/non-fixed/ProfileBody/ProfileBody.tsx @@ -0,0 +1,22 @@ +import styled from 'styled-components'; +import ProfilePageHead from '@components/non-fixed/ProfileBody/ProfilePageHead/ProfilePageHead'; +import ProfileContent from '@components/non-fixed/ProfileBody/ProfileContent/ProfileContent'; +import ProfileDiscordLogo from '@components/non-fixed/ProfileBody/ProfileDiscordLogo/ProfileDiscordLogo'; + +const StyledProfileBody = styled.section` + flex-grow: 1; + overflow-y: scroll; + display: flex; + flex-direction: column; + position: relative; +`; + +export default function ProfileBody() { + return ( + + + + + + ); +} diff --git a/src/components/non-fixed/ProfileBody/ProfileContent/ProfileContent.tsx b/src/components/non-fixed/ProfileBody/ProfileContent/ProfileContent.tsx new file mode 100644 index 0000000..13b0b8d --- /dev/null +++ b/src/components/non-fixed/ProfileBody/ProfileContent/ProfileContent.tsx @@ -0,0 +1,118 @@ +import styled from 'styled-components'; + +const StyledProfileContent = styled.div` + flex-grow: 1; + + margin-top: 44px; + background-color: ${(props) => props.theme.color.grayLight}; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; +`; + +const StyledProfileNameContainer = styled.div` + width: 120px; + height: 68px; + margin-top: 47px; + margin-bottom: 48px; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + row-gap: 4px; +`; + +const StyledUsername = styled.p` + width: 100%; + height: 41px; + font-size: ${(props) => props.theme.textStyle.fontSize.h1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.h1}; + font-weight: 600; + color: ${(props) => props.theme.color.black}; + font-family: Pretendard; + text-align: center; +`; + +const StyledStudy = styled.div` + width: 77px; + height: 23px; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; + font-weight: 500; + font-family: Prtendard; + color: ${(props) => props.theme.color.grayDark}; + text-align: center; +`; + +const StyledSnsContainer = styled.div` + width: 343px; + height: 64px; + border-radius: 20px; + padding: 16px; + background-color: ${(props) => props.theme.color.white}; + display: flex; + justify-content: space-between; + column-gap: 8px; + margin-bottom: 8px; + &:hover { + cursor: pointer; + } + > a { + text-decoration: none; + color: inherit; + display: block; + } +`; + +const SnsImage = styled.img` + width: 32px; + height: 32px; +`; + +const SnsName = styled.p` + width: 231px; + height: 100%; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; + font-family: Pretendard; + font-weight: 500; + color: ${(props) => props.theme.textStyle.lineHeight.body1}; + display: flex; + align-items: center; +`; + +export default function ProfileContent() { + return ( + + + 김정민 + ceos_study + + + + Instagram + + + + + + + Github + + + + + + ); +} diff --git a/src/components/non-fixed/ProfileBody/ProfileDiscordLogo/ProfileDiscordLogo.tsx b/src/components/non-fixed/ProfileBody/ProfileDiscordLogo/ProfileDiscordLogo.tsx new file mode 100644 index 0000000..b00f954 --- /dev/null +++ b/src/components/non-fixed/ProfileBody/ProfileDiscordLogo/ProfileDiscordLogo.tsx @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +const StyledProfileDiscordLogoContainer = styled.div` + position: absolute; + width: 90px; + height: 90px; + top: 65px; + left: 142.5px; + right: 142.5px; + border-radius: 50%; + background-color: white; + z-index: 100; + display: flex; + justify-content: center; + align-items: center; +`; + +const StyledProfileDiscordLogo = styled.img``; + +export default function ProfileDiscordLogo() { + return ( + + + + ); +} diff --git a/src/components/non-fixed/ProfileBody/ProfilePageHead/ProfilePageHead.tsx b/src/components/non-fixed/ProfileBody/ProfilePageHead/ProfilePageHead.tsx new file mode 100644 index 0000000..c7ec660 --- /dev/null +++ b/src/components/non-fixed/ProfileBody/ProfilePageHead/ProfilePageHead.tsx @@ -0,0 +1,64 @@ +import styled from 'styled-components'; + +const StyledProfilePageHead = styled.div` + width: 100%; + height: 64px; + display: flex; + justify-content: center; + align-items: center; +`; + +const StyldProfileHeadContainer = styled.div` + width: 343px; + height: 32px; + display: flex; + justify-content: space-between; + align-items: center; + column-gap: 10px; +`; + +const StyledProfileSpan = styled.span` + font-size: ${(props) => props.theme.textStyle.fontSize.h2}; + font-weight: 600; + line-height: ${(props) => props.theme.textStyle.lineHeight.h2}; + margin-left: 8px; +`; + +const StyledProfileEditContainer = styled.div` + width: 124px; + height: 32px; + background-color: ${(props) => props.theme.color.grayMedium}; + border-radius: 20px; + display: flex; + justify-content: center; + align-items: center; + column-gap: 4px; + + &:hover { + cursor: pointer; + } +`; + +const StyledPencilImage = styled.img``; + +const StyledEditSpan = styled.span` + font-family: pretendard; + font-size: ${(props) => props.theme.textStyle.fontSize.body1}; + line-height: ${(props) => props.theme.textStyle.lineHeight.body1}; + font-weight: 500; + color: ${(props) => props.theme.color.grayDark}; +`; + +export default function ProfilePageHead() { + return ( + + + 내 프로필 + + + 프로필 편집 + + + + ); +} diff --git a/src/components/non-fixed/TabBar/BlankSpace.tsx b/src/components/non-fixed/TabBar/BlankSpace.tsx new file mode 100644 index 0000000..5349661 --- /dev/null +++ b/src/components/non-fixed/TabBar/BlankSpace.tsx @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +const StyledBlankSpace = styled.div` + height: 34px; + width: 100%; +`; + +export default function BlankSpace() { + return ; +} diff --git a/src/components/non-fixed/TabBar/TabBar.tsx b/src/components/non-fixed/TabBar/TabBar.tsx new file mode 100644 index 0000000..c46f9d0 --- /dev/null +++ b/src/components/non-fixed/TabBar/TabBar.tsx @@ -0,0 +1,19 @@ +import TabIconContainer from '@components/non-fixed/TabBar/TabIconContainer'; +import BlankSpace from '@components/non-fixed/TabBar/BlankSpace'; +import styled from 'styled-components'; + +const StyledTabBar = styled.footer` + height: 89px; + background-color: ${(props) => props.theme.color.grayLight}; + display: flex; + flex-direction: column; +`; + +export default function TabBar() { + return ( + + + + + ); +} diff --git a/src/components/non-fixed/TabBar/TabIconContainer.tsx b/src/components/non-fixed/TabBar/TabIconContainer.tsx new file mode 100644 index 0000000..d18546e --- /dev/null +++ b/src/components/non-fixed/TabBar/TabIconContainer.tsx @@ -0,0 +1,105 @@ +import { useRecoilState } from 'recoil'; +import { userPageModeState } from '@context/state/atom'; +import { useNavigate, useLocation } from 'react-router-dom'; +import styled from 'styled-components'; +import { useEffect } from 'react'; + +interface userPageModeAtt { + // 속성은 html dom에 부여하는 것이라서 일부러 $ 표시를 붙여준 것이다 + $userPageMode: boolean; +} + +const StyledTabIconContainer = styled.div` + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + column-gap: 32px; +`; + +const StyledIconContainer = styled.div` + width: 56px; + height: 55px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + + &:hover { + cursor: pointer; + } +`; + +const StyledImage = styled.img` + width: 24px; + height: 24px; + margin-top: 6px; + opacity: ${(props) => (props.$userPageMode === true ? 1 : 0.35)}; +`; + +const StyledTabSpan = styled.span` + font-size: 10px; + font-weight: 500; + line-height: 15px; + margin-bottom: 10px; + + color: ${(props) => + props.$userPageMode === true + ? props.theme.color.black + : props.theme.color.grayDark}; +`; + +export default function TabIconContainer() { + const location = useLocation(); + const [userPageMode, setUserPageMode] = useRecoilState(userPageModeState); + const navigate = useNavigate(); + + // 렌더링 도중에 상태 변경을 진행하면 안됨 => 첫 렌더링 이후에 useEffect() 훅을 통해서 진행해줌ㄴ + useEffect(() => { + if (location.pathname === '/') { + setUserPageMode('friends'); + } else if (location.pathname === '/messages') { + setUserPageMode('messages'); + } else if (location.pathname === '/profile') { + setUserPageMode('profile'); + } + }, [location, setUserPageMode]); + + function handleNavigate(path: string) { + navigate(path); + } + + return ( + + handleNavigate('/')}> + + + 친구 + + + + handleNavigate('/messages')}> + + + 메시지 + + + + handleNavigate('/profile')}> + + + 나 + + + + ); +} diff --git a/src/context/state/atom.ts b/src/context/state/atom.ts new file mode 100644 index 0000000..eebadbc --- /dev/null +++ b/src/context/state/atom.ts @@ -0,0 +1,39 @@ +import { atom } from 'recoil'; +import { messageDataObject } from 'types/type'; + +export const messageDataState = atom({ + key: 'messageData', + default: {} as messageDataObject, +}); + +export const messageDateArrayState = atom({ + key: 'messageDateArray', + default: [] as string[], +}); + +// 추후에 유저들을 identifier 숫자로 구분할 것이기 때문에 확장성을 고려함 +export const userNumberState = atom({ + key: 'userNumber', + default: 1, +}); + +export const isInputBoxFocusedState = atom({ + key: 'isInputBoxFocused', + default: false, +}); + +export const scrollToBottomState = atom({ + key: 'scrollToBottomState', + default: () => {}, +}); + +export const isMessageLikeButtonClickedState = atom({ + key: 'isMessageLikeButtonClicked', + default: false, +}); + +//유저가 '/'에 있으면 friends고, '/messages'에 있으면 messages, '/chat'에 있으면 chat, '/profile에 있으면' profile임 +export const userPageModeState = atom({ + key: 'userPageMode', + default: 'friends', // 추후에 랜딩 페이지가 생기면 그냥 ''이 될 것임 +}); diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx new file mode 100644 index 0000000..d5b1c02 --- /dev/null +++ b/src/hooks/useLocalStorage.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export default function useLocalStorage() { + const [localStorageChatMessageData, setLocalStorageChatMessageData] = + useState(null); + const [ + localStorageChatMessageDateArray, + setLocalStorageChatMessageDateArray, + ] = useState(null); + + useEffect(() => { + const storedChatMessageData = localStorage.getItem('chatMessageData'); + const storedChatMessageDateArray = localStorage.getItem( + 'chatMessageDateArray' + ); + if (storedChatMessageData) { + setLocalStorageChatMessageData(JSON.parse(storedChatMessageData)); + } + + if (storedChatMessageDateArray) { + setLocalStorageChatMessageDateArray( + JSON.parse(storedChatMessageDateArray) + ); + } + }, []); + + return [localStorageChatMessageData, localStorageChatMessageDateArray]; +} diff --git a/src/hooks/useScrollToBottom.tsx b/src/hooks/useScrollToBottom.tsx new file mode 100644 index 0000000..bd1d543 --- /dev/null +++ b/src/hooks/useScrollToBottom.tsx @@ -0,0 +1,14 @@ +import { useRecoilState } from 'recoil'; +import { scrollToBottomState } from '@context/state/atom'; +import { voidFunction } from 'types/type'; + +export default function useScrollToBottom() { + const [scrollToBottom, setScrollToBottom] = + useRecoilState(scrollToBottomState); + + function setScrollFunction(scrollFunction?: voidFunction) { + setScrollToBottom(() => scrollFunction); + } + + return [scrollToBottom, setScrollFunction]; +} 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.tsx b/src/index.tsx index d10be77..88e8a31 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,12 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import './index.css'; import App from './App'; const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement + document.getElementById('root') as HTMLElement ); root.render( - - - + + + ); diff --git a/src/pages/ChatMain.tsx b/src/pages/ChatMain.tsx new file mode 100644 index 0000000..f395d83 --- /dev/null +++ b/src/pages/ChatMain.tsx @@ -0,0 +1,23 @@ +import styled from 'styled-components'; +import ChatHead from '@components/fixed/ChatHead/ChatHead'; +import ChatBody from '@components/non-fixed/ChatBody/ChatBody'; +import ChatInput from '@components/non-fixed/ChatInput/ChatInput'; +import HomeIndicator from '@components/fixed/HomeIndicator/HomeIndicator'; + +const StyledChatMain = styled.main` + position: relative; + display: flex; + flex-direction: column; + height: 100%; +`; + +export default function ChatMain() { + return ( + + + + + + + ); +} diff --git a/src/pages/CommonLayout.tsx b/src/pages/CommonLayout.tsx new file mode 100644 index 0000000..b20f74d --- /dev/null +++ b/src/pages/CommonLayout.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; +import { Outlet } from 'react-router-dom'; +import IphoneStatusBar from '@components/fixed/ChatHead/IphoneStatusBar/IphoneStatusBar'; +import HomeIndicator from '@components/fixed/HomeIndicator/HomeIndicator'; + +const StyledCommonLayout = styled.main` + position: relative; + display: flex; + flex-direction: column; + height: 100%; +`; + +export default function CommonLayout() { + return ( + + + + + + ); +} diff --git a/src/pages/EmptyChat.tsx b/src/pages/EmptyChat.tsx new file mode 100644 index 0000000..0cc73e1 --- /dev/null +++ b/src/pages/EmptyChat.tsx @@ -0,0 +1,16 @@ +import EmptyChatHeadNav from '@components/fixed/ChatHead/ChatHeadNav/EmptyChatHeadNav'; +import ChatInput from '@components/non-fixed/ChatInput/ChatInput'; +import { useParams } from 'react-router-dom'; +import EmptyChatBody from '@components/non-fixed/EmptyChatBody/EmptyChatBody'; + +export default function EmptyChat() { + const { username } = useParams(); + + return ( + <> + + + + + ); +} diff --git a/src/pages/Friends.tsx b/src/pages/Friends.tsx new file mode 100644 index 0000000..f56eeb5 --- /dev/null +++ b/src/pages/Friends.tsx @@ -0,0 +1,19 @@ +import FriendsBody from '@components/non-fixed/FriendsBody/FriendsBody'; +import TabBar from '@components/non-fixed/TabBar/TabBar'; +import { useRecoilState } from 'recoil'; +import { userNumberState } from '@context/state/atom'; +import { useEffect } from 'react'; + +export default function Friends() { + const [userNumber, setUserNumber] = useRecoilState(userNumberState); + + useEffect(() => { + setUserNumber(1); + }, []); + return ( + <> + + + + ); +} diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx new file mode 100644 index 0000000..ed6a026 --- /dev/null +++ b/src/pages/Messages.tsx @@ -0,0 +1,11 @@ +import MessageBody from '@components/non-fixed/MessageBody/MessageBody'; +import TabBar from '@components/non-fixed/TabBar/TabBar'; + +export default function Messages() { + return ( + <> + + + + ); +} diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..08b7c8b --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import TabBar from '@components/non-fixed/TabBar/TabBar'; +import { Link } from 'react-router-dom'; + +const StyledNotFoundContainer = styled.div` + flex-grow: 1; + overflow-y: scroll; +`; + +export default function NotFound() { + return ( + <> + + This is not found page.Go back to home page + Go to home page + + + + ); +} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx new file mode 100644 index 0000000..d5bea16 --- /dev/null +++ b/src/pages/Profile.tsx @@ -0,0 +1,11 @@ +import ProfileBody from '@components/non-fixed/ProfileBody/ProfileBody'; +import TabBar from '@components/non-fixed/TabBar/TabBar'; + +export default function Profile() { + return ( + <> + + + + ); +} diff --git a/src/pages/theme.ts b/src/pages/theme.ts new file mode 100644 index 0000000..db74824 --- /dev/null +++ b/src/pages/theme.ts @@ -0,0 +1,39 @@ +const theme = { + color: { + key: '#5865F2', + white: '#FFFFFF', + grayLight: '#F2F3F5', + grayMedium: '#EBEBEB', + grayDark: '#4E5058', + black: '#060607', + }, + textStyle: { + fontSize: { + h1: '32px', + h2: '20px', + h3: '16px', + body1: '15px', + body2: '15px', + label: '13px', + caption: '10px', + thirtyTwoPixel: '32px', + sixteenPixel: '16px', + eightPixel: '8px', + fourPixel: '4px', + }, + lineHeight: { + h1: '150%', + h2: '150%', + h3: '150%', + body1: '150%', + body2: '150%', + label: '150%', + caption: '150%', + }, + }, + gridStyle: { + display: 'grid', + }, +}; + +export default theme; diff --git a/src/styles/globalStyles.tsx b/src/styles/globalStyles.tsx new file mode 100644 index 0000000..6a4c24d --- /dev/null +++ b/src/styles/globalStyles.tsx @@ -0,0 +1,40 @@ +import { createGlobalStyle } from 'styled-components'; +import reset from 'styled-reset'; + +const GlobalStyles = createGlobalStyle` + ${reset} + * { + box-sizing: border-box; + } + html { + background-color: #E5E6EB; + + height: 100dvh; + } + body { + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #FFFFFF; + opacity: 0.95; + margin: 0 auto; + /* height: 812px; */ + height: 100%; + width: 375px; + } + #root { + height: 100dvh; + width: 100%; + } + + /* 스크롤 바 관련 UI를 제거하는 속성 원하는 요소에 scroll-box 클래스를 부여하면 됨 */ +.scroll-box { + overflow-x: hidden; + -ms-overflow-style: none; +} + +.scroll-box::-webkit-scrollbar { + display: none; +} + +`; + +export default GlobalStyles; diff --git a/src/styles/styledComponents.ts b/src/styles/styledComponents.ts new file mode 100644 index 0000000..4c6c489 --- /dev/null +++ b/src/styles/styledComponents.ts @@ -0,0 +1,20 @@ +import { css } from 'styled-components'; + +export const hoverCursor = css` + &:hover { + cursor: pointer; + } +`; + +export const dateBeforeAfter = css` + content: ''; + position: absolute; + top: 50%; + width: 117px; + height: 1px; + background-color: ${(props) => props.theme.color.grayMedium}; +`; + +export const chatBodyDivElementGap = css` + margin-bottom: 24px; +`; diff --git a/src/styles/theme.ts b/src/styles/theme.ts new file mode 100644 index 0000000..db74824 --- /dev/null +++ b/src/styles/theme.ts @@ -0,0 +1,39 @@ +const theme = { + color: { + key: '#5865F2', + white: '#FFFFFF', + grayLight: '#F2F3F5', + grayMedium: '#EBEBEB', + grayDark: '#4E5058', + black: '#060607', + }, + textStyle: { + fontSize: { + h1: '32px', + h2: '20px', + h3: '16px', + body1: '15px', + body2: '15px', + label: '13px', + caption: '10px', + thirtyTwoPixel: '32px', + sixteenPixel: '16px', + eightPixel: '8px', + fourPixel: '4px', + }, + lineHeight: { + h1: '150%', + h2: '150%', + h3: '150%', + body1: '150%', + body2: '150%', + label: '150%', + caption: '150%', + }, + }, + gridStyle: { + display: 'grid', + }, +}; + +export default theme; diff --git a/src/types/type.tsx b/src/types/type.tsx new file mode 100644 index 0000000..1508ff6 --- /dev/null +++ b/src/types/type.tsx @@ -0,0 +1,23 @@ +export type dateStringProps = { + dateString: string; +}; + +export type isMessageOwnerEqualsWithModeProps = { + isEqual: boolean; +}; + +export type processedMessageData = { + content: string; + createdAt: string; + createdDate: string; + from: number; + like: boolean; +}; + +export type processedMessageDataArray = processedMessageData[]; + +export interface messageDataObject { + [key: string]: processedMessageDataArray; +} + +export type voidFunction = () => void; diff --git a/src/utils/makeTimeString.js b/src/utils/makeTimeString.js new file mode 100644 index 0000000..f9ad9b9 --- /dev/null +++ b/src/utils/makeTimeString.js @@ -0,0 +1,15 @@ +// 사용자의 시간대 오프셋(시간대에 따라 분 단위로 설정) +function getUserTimezoneOffset() { + // 예시: 한국 시간대의 경우 UTC+9 + return 9 * 60; // 분 단위로 설정 +} + +// ISO 문자열로 시간을 보정하는 함수 +export function adjustTimeForUserLocation() { + const currentTime = new Date(); + const userTimezoneOffset = getUserTimezoneOffset(); + const adjustedTime = new Date( + currentTime.getTime() + userTimezoneOffset * 60000 + ); // 시간대 오프셋을 밀리초 단위로 변환하여 시간 보정 + return adjustedTime.toISOString(); +} diff --git a/src/utils/makeUserIdNumber.js b/src/utils/makeUserIdNumber.js new file mode 100644 index 0000000..775a918 --- /dev/null +++ b/src/utils/makeUserIdNumber.js @@ -0,0 +1,11 @@ +export function makeUserIdNumber(name) { + // 각 문자를 아스키코드 값으로 바꿔서 사용자 id를 바꿈. + let hash = 0; + if (name.length === 0) return hash; + for (let i = 0; i < name.length; i++) { + const char = name.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32bit integer + } + return hash; +} diff --git a/src/utils/sortArrayByDate.js b/src/utils/sortArrayByDate.js new file mode 100644 index 0000000..9076e3d --- /dev/null +++ b/src/utils/sortArrayByDate.js @@ -0,0 +1,8 @@ +export default function sortByDate(a, b) { + // 'yyyy-mm-dd' 형식의 문자열을 날짜로 변환하여 비교 + const dateA = new Date(a); + const dateB = new Date(b); + + // 오름차순 정렬 + return dateA - dateB; +} diff --git a/tsconfig.json b/tsconfig.json index a273b0c..f37122d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,8 @@ { + "extends": "./tsconfig.paths.json", "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -18,9 +15,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "allowImportingTsExtensions": true }, - "include": [ - "src" - ] + "include": ["src"] } diff --git a/tsconfig.paths.json b/tsconfig.paths.json new file mode 100644 index 0000000..46cbf16 --- /dev/null +++ b/tsconfig.paths.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@components/*": ["./components/*"], + "@assets/*": ["./assets/*"], + "@styles/*": ["./styles/*"], + "@context/*": ["./context/*"], + "@hooks/*": ["./hooks/*"], + "@utils/*": ["./utils/*"], + "@pages/*": ["./pages/*"], + "@_type/*": ["./type/*"] + } + } +}