diff --git a/package-lock.json b/package-lock.json index c27bbe4..cee3006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,16 @@ "@types/react-dom": "^18.2.22", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "recoil": "^0.7.7", + "styled-components": "^6.1.13", "typescript": "^4.9.5", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "@types/react-router-dom": "^5.3.3", + "@types/styled-components": "^5.1.34" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2288,6 +2295,24 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3338,6 +3363,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", + "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==", + "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", @@ -4103,6 +4136,22 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dev": true, + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4227,6 +4276,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -4290,6 +4360,22 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/styled-components": { + "version": "5.1.34", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.34.tgz", + "integrity": "sha512-mmiVvwpYklFIv9E8qfxuPyIt/OuyIrn6gMOAMOFUO3WJfSrSE+sGUoa4PiZj77Ut7bKZpaa6o1fBKS/4TOEvnA==", + "dev": true, + "dependencies": { + "@types/hoist-non-react-statics": "*", + "@types/react": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" + }, "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 +5836,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", @@ -6182,6 +6276,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 +6474,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", @@ -8861,6 +8973,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", @@ -8953,6 +9070,21 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -14864,6 +14996,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", + "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==", + "dependencies": { + "@remix-run/router": "1.19.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz", + "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==", + "dependencies": { + "@remix-run/router": "1.19.2", + "react-router": "6.26.2" + }, + "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 +15130,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 +15900,11 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -16260,6 +16446,33 @@ "webpack": "^5.0.0" } }, + "node_modules/styled-components": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.13.tgz", + "integrity": "sha512-M0+N2xSnAtwcVAQeFEsGWFFxXDftHUD7XrKla06QbpUMmbmtFBMMTcKWvFXtWxuD5qQkB8iU5gk6QASlx2ZRMw==", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, "node_modules/stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", @@ -16275,6 +16488,11 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", diff --git a/package.json b/package.json index ea335d3..051502e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "@types/react-dom": "^18.2.22", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "recoil": "^0.7.7", + "styled-components": "^6.1.13", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, @@ -39,5 +42,9 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/react-router-dom": "^5.3.3", + "@types/styled-components": "^5.1.34" } } diff --git a/public/Profile/Ceos.svg b/public/Profile/Ceos.svg new file mode 100644 index 0000000..78913ac --- /dev/null +++ b/public/Profile/Ceos.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/Profile/cat.svg b/public/Profile/cat.svg new file mode 100644 index 0000000..4d0d8c4 --- /dev/null +++ b/public/Profile/cat.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/Profile/seunghunse.svg b/public/Profile/seunghunse.svg new file mode 100644 index 0000000..d9186f1 --- /dev/null +++ b/public/Profile/seunghunse.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/Profile/simonse.svg b/public/Profile/simonse.svg new file mode 100644 index 0000000..7b0eb5b --- /dev/null +++ b/public/Profile/simonse.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/Profile/sws.svg b/public/Profile/sws.svg new file mode 100644 index 0000000..e364469 --- /dev/null +++ b/public/Profile/sws.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/font/PretendardVariable.woff2 b/public/font/PretendardVariable.woff2 new file mode 100644 index 0000000..49c54b5 Binary files /dev/null and b/public/font/PretendardVariable.woff2 differ diff --git a/public/index.html b/public/index.html index aa069f2..2feb613 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + FaceBook-Messenger diff --git a/public/logo512.png b/public/logo512.png deleted file mode 100644 index a4e47a6..0000000 Binary files a/public/logo512.png and /dev/null differ diff --git a/public/mockChatData.json b/public/mockChatData.json new file mode 100644 index 0000000..604f56a --- /dev/null +++ b/public/mockChatData.json @@ -0,0 +1,46 @@ +{ + "chatMessages": { + "1": { + "users": [ + { "id": 5, "name": "진나경" }, + { "id": 1, "name": "세오스" } + ], + "messages": [ + { "userId": 5, "content": "안녕하세요, 반갑습니다!", "time": "2024-10-07T14:21:21.238Z" }, + { "userId": 1, "content": "안녕하세요!", "time": "2024-10-07T14:21:21.238Z" }, + { "userId": 1, "content": "과제 하셨어요?", "time": "2024-10-07T14:21:21.238Z" } + ] + }, + "2": { + "users": [ + { "id": 5, "name": "진나경" }, + { "id": 2, "name": "승헌스" } + ], + "messages": [ + { "userId": 5, "content": "승헌스님, 잘 지내시나요?", "time": "2024-10-07T14:34:21.238Z" }, + { "userId": 2, "content": "막 이러시구~", "time": "2024-10-07T14:40:21.238Z" } + ] + }, + "3": { + "users": [ + { "id": 5, "name": "진나경" }, + { "id": 3, "name": "스윙스" } + ], + "messages": [ + { "userId": 5, "content": "스윙스님, 잘 지내시나요?", "time": "2024-10-07T14:34:21.238Z" }, + { "userId": 3, "content": "니가뭔상관인데", "time": "2024-10-07T14:35:21.238Z" } + ] + }, + "4": { + "users": [ + { "id": 5, "name": "진나경" }, + { "id": 4, "name": "시몬스" } + ], + "messages": [ + { "userId": 5, "content": "시몬스님, 잘 지내시나요?", "time": "2024-10-07T14:34:21.238Z" }, + { "userId": 4, "content": "드럼이나 쳐라", "time": "2024-10-07T14:40:21.238Z" } + ] + } + } +} + diff --git a/public/mockUserData.json b/public/mockUserData.json new file mode 100644 index 0000000..10fd149 --- /dev/null +++ b/public/mockUserData.json @@ -0,0 +1,29 @@ +[ + { + "id": 1, + "name": "세오스", + "profileImage": "/Profile/Ceos.svg" + }, + { + "id": 2, + "name": "승헌스", + "profileImage": "/Profile/seunghunse.svg" + }, + { + "id": 3, + "name": "스윙스", + "profileImage": "/Profile/sws.svg" + + }, + { + "id": 4, + "name": "시몬스", + "profileImage": "/Profile/simonse.svg" + } + ,{ + "id": 5, + "name": "진나경", + "profileImage": "/Profile/cat.svg" + } + ] + \ No newline at end of file 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 5381007..5a111ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,26 @@ -function App() { +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import GlobalStyle from './GlobalStyle'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import FriendListPage from './Pages/FriendListPage'; +import ChatListPage from './Pages/ChatListPage'; +import ChatRoom from './Pages/ChatRoom'; +import StoryPage from './Pages/StoryPage'; + +const App: React.FC = () => { return ( -
-

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

-
+ + + + + }/> + }/> + } /> + } /> + + + ); -} +}; -export default App; +export default App; \ No newline at end of file diff --git a/src/GlobalStyle.tsx b/src/GlobalStyle.tsx new file mode 100644 index 0000000..8c7ac29 --- /dev/null +++ b/src/GlobalStyle.tsx @@ -0,0 +1,41 @@ +import { createGlobalStyle } from 'styled-components'; + +const GlobalStyle = createGlobalStyle` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Pretendard Variable', sans-serif; + } + + body { + display: flex; + justify-content: center; + align-items: center; + margin: 0; + height: 100vh; + background-color: #f0f0f0; + overflow: hidden; + } + + #root { + width: 100%; + max-width: 375px; + max-height: 812px; + height: 100%; + border-radius: 40px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + background-color: #ffffff; + } + + @font-face { + font-family: 'Pretendard Variable'; + font-weight: 45 920; + font-style: normal; + font-display: swap; + src: url('/font/PretendardVariable.woff2') format('woff2-variations'); + } +`; + +export default GlobalStyle; + diff --git a/src/Pages/ChatListPage/components/ActiveStatus/index.tsx b/src/Pages/ChatListPage/components/ActiveStatus/index.tsx new file mode 100644 index 0000000..4c7f0c3 --- /dev/null +++ b/src/Pages/ChatListPage/components/ActiveStatus/index.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { ActiveStatusLayout, StatusWrapper, Photo, Name, StatusContainer, StatusDot } from "./style"; +import { useNavigate } from "react-router-dom"; +import { useRecoilValue } from 'recoil'; +import { userDataState } from '../../../../recoil/atom'; + +const ActiveStatus: React.FC = () => { + const navigate = useNavigate(); + const users = useRecoilValue(userDataState); + + const handleUserClick = (userId: number) => { + navigate(`/chat/${userId}`); + }; + + return ( + + {users + .filter(user => user.id !== 5) // id가 5인 사용자, 진나경 제외 + .map((user) => ( + handleUserClick(user.id)}> + + + + + {user.name} + + ))} + + ); +}; + +export default ActiveStatus; + diff --git a/src/Pages/ChatListPage/components/ActiveStatus/style.tsx b/src/Pages/ChatListPage/components/ActiveStatus/style.tsx new file mode 100644 index 0000000..1bc3b4b --- /dev/null +++ b/src/Pages/ChatListPage/components/ActiveStatus/style.tsx @@ -0,0 +1,43 @@ +import styled from "styled-components"; + +export const ActiveStatusLayout = styled.div` + display: flex; + padding: 12px 0; + gap: 18px; +`; + +export const StatusWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: pointer; +`; + +export const Photo= styled.img` + display: flex; + border-radius: 50%; +` + +export const Name= styled.div` + display: flex; + font-size: 12px; + color: #454C53; +` + +export const StatusContainer = styled.div` + position: relative; /* 부모 컨테이너를 relative로 설정 */ + display: inline-block; /* 이미지와 점을 함께 표시 */ +`; + +export const StatusDot = styled.div` + position: absolute; + bottom: 0px; /* 이미지 오른쪽 하단에 맞춰서 위치 조정 */ + right: 1px; + width: 16px; + height: 16px; + background-color: #45D658; + border-radius: 50%; + z-index: 2; /* 사진 위에 표시될 수 있도록 z-index 설정 */ + border: 3px solid white; +`; \ No newline at end of file diff --git a/src/Pages/ChatListPage/components/ChatList/index.tsx b/src/Pages/ChatListPage/components/ChatList/index.tsx new file mode 100644 index 0000000..b822f8a --- /dev/null +++ b/src/Pages/ChatListPage/components/ChatList/index.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { userDataState, chatDataState } from '../../../../recoil/atom'; +import { ChatListLayout, ChatItem, LastMessage, Timestamp, ChatName, UserPhoto, ChatInfo, MesseageInfo } from './style'; +import { NoResult } from '../../../FriendListPage/FriendList/style'; +import { SearchListProps } from '../../../FriendListPage/FriendList'; + +// 타임스탬프 포맷팅 함수 +const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +}; + +const ChatList: React.FC = ({ searchTerm }) => { + const users = useRecoilValue(userDataState); // atom에서 사용자 데이터 가져오기 + const chatRooms = useRecoilValue(chatDataState); // atom에서 채팅 데이터 가져오기 + const navigate = useNavigate(); + + // 검색어에 따라 채팅 목록 필터링 + const filteredChatRooms = Object.keys(chatRooms).filter((chatId) => { + const chat = chatRooms[chatId]; + return chat.users.some((user) => user.name.toLowerCase().includes(searchTerm.toLowerCase())); + }); + + // 각 채팅방의 마지막 메시지를 기준으로 정렬 + const sortedChatRooms = filteredChatRooms.sort((a, b) => { + const lastMessageA = chatRooms[a].messages[chatRooms[a].messages.length - 1]; + const lastMessageB = chatRooms[b].messages[chatRooms[b].messages.length - 1]; + + // 메시지가 없을 경우 처리 + const timeA = lastMessageA ? new Date(lastMessageA.time).getTime() : 0; + const timeB = lastMessageB ? new Date(lastMessageB.time).getTime() : 0; + + return timeB - timeA; // 내림차순으로 정렬 + }); + + return ( + + {filteredChatRooms.length > 0 ? ( + sortedChatRooms.map((chatId) => { + const chat = chatRooms[chatId]; + + // 마지막 메시지 가져오기 + const lastMessage = chat.messages[chat.messages.length - 1]; // 마지막 메시지 + const opponentId = chat.users.find((user) => user.id !== chat.users[0].id)?.id; // 상대방 ID + const opponentData = users.find((user) => user.id === opponentId); // 상대방 정보 찾기 + + return ( + navigate(`/chat/${opponentId}`)}> + + + {opponentData?.name} + + {lastMessage.content} + · {formatTimestamp(lastMessage.time)} + + + + ); + }) + ) : ( + 검색 결과가 없습니다. 🥹 + )} + + ); +}; + +export default ChatList; diff --git a/src/Pages/ChatListPage/components/ChatList/style.tsx b/src/Pages/ChatListPage/components/ChatList/style.tsx new file mode 100644 index 0000000..2fa3db2 --- /dev/null +++ b/src/Pages/ChatListPage/components/ChatList/style.tsx @@ -0,0 +1,61 @@ +import styled from "styled-components"; + +export const ChatListLayout = styled.div` +display: flex; +flex-direction: column; +`; + +export const ChatItem = styled.div` + display: flex; + align-items: center; + padding: 12px 0; + gap: 12px; + cursor: pointer; + &:hover { + background-color: #e9eff2; + } +`; + +export const UserPhoto = styled.img` + width: 56px; + height: 56px; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; + color: white; +`; + +export const ChatInfo = styled.div` + display: flex + flex-direction: column; +`; + +export const MesseageInfo = styled.div` + display: flex; + justify-content: center; + gap: 7px; +`; + +export const ChatName = styled.p` + font-weight: bold; + margin: 0; + color: #333; + font-size: 16px; + flex: 1; /* 남은 공간을 차지하도록 설정 */ +`; + +export const LastMessage = styled.p` + flex: 1; + margin: 0; + color: #606770; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; /* 긴 메시지에 ellipsis 처리 */ + white-space: nowrap; +`; + +export const Timestamp = styled.span` + font-size: 14px; + color: #b0b3b8; +`; diff --git a/src/Pages/ChatListPage/index.tsx b/src/Pages/ChatListPage/index.tsx new file mode 100644 index 0000000..6551536 --- /dev/null +++ b/src/Pages/ChatListPage/index.tsx @@ -0,0 +1,27 @@ +import React,{ useState } from "react"; +import Header from "../../components/Header"; +import { PageContainer } from "../FriendListPage/style"; +import BottomNav from "../../components/BottomNav"; +import ActiveStatus from "./components/ActiveStatus"; +import ChatList from "./components/ChatList"; +import StatusBar from "../../components/StatusBar"; + +const ChatListPage: React.FC = () => { + const [searchTerm, setSearchTerm] = useState(''); + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + return ( + + +
+ + + + + ) +}; + +export default ChatListPage; \ No newline at end of file diff --git a/src/Pages/ChatRoom/components/Chats/index.tsx b/src/Pages/ChatRoom/components/Chats/index.tsx new file mode 100644 index 0000000..f745568 --- /dev/null +++ b/src/Pages/ChatRoom/components/Chats/index.tsx @@ -0,0 +1,197 @@ +import { forwardRef, useEffect, useState } from 'react'; +import UserInfo from '../UserInfo'; +import { Chat, MyMessage, OtherMessage, OtherMessageContainer, MessageTime, EmojiPicker, Emoji, MymessageEmoji, OtherMessageEmoji } from './style'; + +export interface MessageProps{ + userId: number; + content: string; + time: string; + emoji?: string; +} + +interface ChatProps { + currentUserId: number; // 현재 사용자 ID + opponentUserId: number; // 대화 상대방 ID + messages: MessageProps[]; // 메시지 배열 + getProfileImage: (index: number) => JSX.Element | null; // 프로필 이미지 가져오는 함수 +} + +// 날짜에서 요일을 가져오는 함수 +const getDayLabel = (date: Date) => { + const options: Intl.DateTimeFormatOptions = { weekday: 'long' }; + const day = date.toLocaleDateString('ko-KR', options); + return `(${day.charAt(0)})`; // 요일의 첫 글자 반환 +}; + +// 사용 가능한 이모지 목록 +const emojiList = ['👍🏻', '🩷', '😍', '😄', '😯', '😢', '😡']; + +// Chats 컴포넌트 정의 +const Chats = forwardRef(({ currentUserId, opponentUserId, messages, getProfileImage }, ref) => { + const [selectedEmoji, setSelectedEmoji] = useState<{ [key: number]: string }>({}); // 선택된 이모지 상태 + const [visibleEmojiPicker, setVisibleEmojiPicker] = useState<{ [key: number]: boolean }>({}); // 이모지 피커의 가시성 상태 + + // 컴포넌트가 마운트될 때 로컬 스토리지에서 이모지 가져오기 + useEffect(() => { + const storedEmojis = localStorage.getItem('selectedEmojis'); + if (storedEmojis) { + setSelectedEmoji(JSON.parse(storedEmojis)); // 저장된 이모지 상태 업데이트 + } + }, []); + + // 선택된 이모지가 변경될 때 로컬 스토리지 업데이트 + useEffect(() => { + if (Object.keys(selectedEmoji).length > 0) { + localStorage.setItem('selectedEmojis', JSON.stringify(selectedEmoji)); // 로컬 스토리지에 저장 + } + }, [selectedEmoji]); + + // 이모지 클릭 핸들러 + const handleEmojiClick = (index: number, emoji: string) => { + setSelectedEmoji((prev) => ({ ...prev, [index]: emoji })); // 선택된 이모지 상태 업데이트 + setVisibleEmojiPicker((prev) => ({ ...prev, [index]: false })); // 이모지 피커 숨기기 + }; + + // 이모지 피커 토글 핸들러 + const toggleEmojiPicker = (index: number) => { + setVisibleEmojiPicker((prev) => ({ ...prev, [index]: !prev[index] })); // 해당 인덱스의 이모지 피커 가시성 전환 + }; + + // 이모지를 제거하는 함수 + const handleEmojiDoubleClick = (index: number) => { + setSelectedEmoji((prev) => { + const newSelectedEmoji = { ...prev }; + if (newSelectedEmoji[index]) { // 해당 인덱스의 이모지가 존재하는 경우 + delete newSelectedEmoji[index]; // 이모지 제거 + } + // 로컬 스토리지 업데이트 + localStorage.setItem('selectedEmojis', JSON.stringify(newSelectedEmoji)); + return newSelectedEmoji; // 새로운 상태 반환 + }); + }; + + // 이모지를 메시지 내용에 추가하는 함수 + const updateMessageWithEmoji = (index: number, content: string, emoji?: string) => { + if (emoji) { + return `${content} ${emoji}`; // 이모지를 추가 + } + return content; // 이모지가 없으면 원래 내용 반환 + }; + + // 클릭과 더블 클릭을 구분하기 위한 타이머 +let clickTimeout: NodeJS.Timeout | null = null; + +// 메시지를 클릭할 때 이모지 피커를 토글하는 핸들러 +const handleMessageClick = (event: React.MouseEvent, index: number) => { + event.preventDefault(); // 기본 클릭 동작 방지 + + // 클릭 이벤트가 발생했을 때 타이머 설정 + if (clickTimeout) { + clearTimeout(clickTimeout); // 기존 타이머를 초기화 + } + + // 더블 클릭이 아니라면 이모지 피커를 토글 + clickTimeout = setTimeout(() => { + toggleEmojiPicker(index); + }, 250); // 250ms 이내에 더블 클릭이 발생하지 않으면 클릭으로 간주 +}; +// 메시지를 더블 클릭할 때 이모지를 제거하는 이벤트 핸들러 +const handleMessageDoubleClick = (index: number, event: React.MouseEvent) => { + event.stopPropagation(); // 이벤트 전파를 막아 다른 클릭 이벤트가 실행되지 않게 함 + + if (clickTimeout) { + clearTimeout(clickTimeout); // 더블 클릭 시 클릭 타이머 초기화 + clickTimeout = null; // 타이머를 null로 설정 + } + + handleEmojiDoubleClick(index); // 이모지 제거 처리 +}; + + +return ( + + {/* 현재 사용자 정보 표시 */} + {messages.map((msg, index) => { + const isMyMessage = msg.userId === currentUserId; // 메시지가 현재 사용자의 것인지 확인 + + const currentTime = new Date(msg.time); // 현재 메시지의 시간 + const previousTime = index > 0 ? new Date(messages[index - 1].time) : null; // 이전 메시지의 시간 + + // 메시지의 시간 표시 여부 결정 + const shouldShowTime = index === 0 || + (previousTime && currentTime.toLocaleDateString() !== previousTime.toLocaleDateString()) || + (previousTime && (currentTime.getTime() - previousTime.getTime() > 1800000)) || + (previousTime && (msg.userId === messages[index - 1].userId && + currentTime.getTime() - previousTime.getTime() > 10 * 1000)); + + // 메시지가 그룹의 첫 번째 메시지인지 확인 + const isFirstMessage = shouldShowTime || index === 0 || messages[index - 1]?.userId !== msg.userId; + + const isLastBeforeTimeMessage = shouldShowTime && index > 0 && messages[index - 1]?.userId === msg.userId; + + const isLastOtherMessage = + !isMyMessage && + (isLastBeforeTimeMessage || index === messages.length - 1 || messages[index + 1]?.userId === currentUserId); + + const isGroupEnd = isLastBeforeTimeMessage || index === messages.length - 1 || messages[index + 1]?.userId !== msg.userId; + + const isMiddleMessage = !isFirstMessage && !isGroupEnd; // 중간 메시지인지 확인 + + const emoji = selectedEmoji[index]; // 현재 메시지의 이모지 가져오기 + + return ( +
+ {shouldShowTime && ( + + {`${getDayLabel(currentTime)} ${currentTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`} {/* 메시지 시간 표시 */} + + )} +
handleMessageClick(event, index)} style={{ cursor: 'pointer', position: 'relative' }}> + {isMyMessage ? ( // 내 메시지일 경우 + handleMessageDoubleClick(index, event)} // 더블 클릭 시 이모지 제거 + > + {updateMessageWithEmoji(index, msg.content)} + {emoji && {emoji}} {/* 메시지 위에 이모지 표시 */} + + ) : ( // 다른 사용자의 메시지일 경우 + + {getProfileImage(isLastOtherMessage ? index : index - 1)} + handleMessageDoubleClick(index, event)} // 더블 클릭 시 이모지 제거 + > + {updateMessageWithEmoji(index, msg.content)} + {emoji && {emoji}} {/* 메시지 위에 이모지 표시 */} + + + )} +
+ {visibleEmojiPicker[index] && ( // 이모지 피커가 보이는 경우 + + {emojiList.map((emoji, emojiIndex) => ( + handleEmojiClick(index, emoji)}> {/* 이모지 클릭 시 추가 */} + {emoji} + + ))} + + )} +
+ ); + })} +
+ ); +}); + +export default Chats; + diff --git a/src/Pages/ChatRoom/components/Chats/style.tsx b/src/Pages/ChatRoom/components/Chats/style.tsx new file mode 100644 index 0000000..a765455 --- /dev/null +++ b/src/Pages/ChatRoom/components/Chats/style.tsx @@ -0,0 +1,135 @@ +import styled from 'styled-components'; + +export const Chat = styled.div` + display: flex; + flex-direction: column; + height: 645px; + overflow-y: auto; + + &::-webkit-scrollbar { + display: none; + } +`; + +export const MyMessage = styled.div<{ $isFirstMessage: boolean, $isGroupEnd: boolean, $isMiddleMessage: boolean, $hasEmoji: boolean }>` + align-self: flex-end; + position: relative; + background: var(--main-blue-02, #6052FF); + color: white; + padding: 6px 12px; + max-width: 280px; + word-wrap: break-word; + + /* 메시지 위치에 따라 다른 border-radius 적용 */ + border-radius: ${({ $isFirstMessage, $isGroupEnd, $isMiddleMessage }) => { + if ($isFirstMessage) return '16px 16px 4px 16px'; // 첫 번째 메시지 + if ($isGroupEnd) return '16px 4px 16px 16px'; // 마지막 메시지 + if ($isMiddleMessage) return '16px 4px 4px 16px'; // 중간 메시지 + return '16px'; // 기본 값 + }}; + + margin-bottom: ${({ $isGroupEnd, $hasEmoji }) => { + if ($isGroupEnd && !$hasEmoji) { + return '12px'; // 그룹 마지막이고 이모지가 없을 때 + } else if ($isGroupEnd && $hasEmoji) { + return '10px'; // 그룹 마지막이고 이모지가 있을 때 + } else if (!$isGroupEnd && !$hasEmoji) { + return '2px'; // 그룹 마지막이 아니고 이모지가 없을 때 + } else { + return '15px'; // 그룹 마지막이 아니고 이모지가 있을 때 + } +}}; +`; + +export const OtherMessageContainer = styled.div<{ $hasProfileImg: boolean, $isGroupEnd: boolean }>` + display: flex; + align-items: flex-start; + margin-left: ${({ $hasProfileImg }) => $hasProfileImg ? '0' : '32px'}; + margin-bottom: ${({ $isGroupEnd }) => $isGroupEnd ? '12px' : '2px'}; + border-radius: ${({$isGroupEnd}) => $isGroupEnd ? '4px 16px 16px 16px' : '4px 16px 16px 4px'}; +`; + +export const OtherMessage = styled.div<{ $isFirstMessage: boolean, $isMiddleMessage: boolean, $isGroupEnd: boolean, $hasEmoji: boolean }>` + position: relative; + background: var(--gray-scale-100, #E8EBED); + color: #333; + padding: 6px 12px; + max-width: 280px; + word-wrap: break-word; + + /* border-radius 계산 순서 조정 */ + border-radius: ${({ $isFirstMessage, $isMiddleMessage, $isGroupEnd }) => { + if ($isFirstMessage) return '16px 16px 16px 4px'; // 첫 번째 메시지 + if ($isMiddleMessage) return '4px 16px 16px 4px'; // 중간 메시지 + if ($isGroupEnd) return '4px 16px 16px 16px'; // 마지막 메시지 + return '16px'; // 기본 값 + }}; + + margin-bottom: ${({ $isGroupEnd, $hasEmoji }) => { + if ($isGroupEnd && !$hasEmoji) { + return '12px'; // 그룹 마지막이고 이모지가 없을 때 + } else if ($isGroupEnd && $hasEmoji) { + return '0'; // 그룹 마지막이고 이모지가 있을 때 + } else if (!$isGroupEnd && !$hasEmoji) { + return '2px'; // 그룹 마지막이 아니고 이모지가 없을 때 + } else { + return '15px'; // 그룹 마지막이 아니고 이모지가 있을 때 + } +}}; +`; +export const MessageTime = styled.div` + align-self: center; + font-size: 0.8rem; + color: gray; + text-align: center; + margin: 32px 0 20px 0; /* 메시지 간 간격 */ +`; +export const EmojiPicker = styled.div` + display: flex; + width: 340px; + height: 36px; + gap: 12px; + justify-content: center; + align-items: center; + margin-bottom: 6px; + background-color: var(--gray-scale-50, #F7F8F9); /* 배경색 */ + border-radius: 16px; /* 모서리 둥글게 */ + padding: 6px 12px; /* 안쪽 여백 */ +`; + +export const Emoji = styled.span` + width: 24px; + height: 24px; + cursor: pointer; /* 마우스 포인터 모양 변경 */ + margin: 0 5px; /* 이모지 사이의 간격 */ + font-size: 20px; /* 이모지 크기 */ + + &:hover { + transform: scale(1.2); /* 호버 시 이모지 확대 */ + transition: transform 0.2s; /* 확대 애니메이션 */ + } +`; + +export const MymessageEmoji = styled.div` + position: absolute; + text-align: center; + bottom: -15px; /* 이미지 오른쪽 하단에 맞춰서 위치 조정 */ + right: 3px; + width: 25px; + height: 23px; + border-radius: 40%; + z-index: 2; /* 사진 위에 표시될 수 있도록 z-index 설정 */ + background-color: white; +`; + +export const OtherMessageEmoji = styled.div` + position: absolute; + text-align: center; + bottom: -15px; /* 이미지 오른쪽 하단에 맞춰서 위치 조정 */ + left: 3px; + width: 25px; + height: 23px; + border-radius: 40%; + z-index: 2; /* 사진 위에 표시될 수 있도록 z-index 설정 */ + background-color: white; +`; \ No newline at end of file diff --git a/src/Pages/ChatRoom/components/InputBar/index.tsx b/src/Pages/ChatRoom/components/InputBar/index.tsx new file mode 100644 index 0000000..5d2ac32 --- /dev/null +++ b/src/Pages/ChatRoom/components/InputBar/index.tsx @@ -0,0 +1,60 @@ +import React, { useState, useRef } from 'react'; +import camera from '../../../../assets/ChatRoom/Camera.svg'; +import gallery from '../../../../assets/ChatRoom/Gallery.svg'; +import Good from '../../../../assets/ChatRoom/Good.svg'; +import sendIcon from '../../../../assets/ChatRoom/SendIcon.svg'; +import { InputBarContainer, Camera, Gallery, InputField, GoodButton, SendButton} from './style'; + +interface InputBarProps { + onSendMessage: (message: string) => void; +} + +const InputBar: React.FC = ({ onSendMessage }) => { + const messageRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); // 입력창 포커스 상태 관리 + + const handleSendMessage = () => { + if (messageRef.current) { + const message = messageRef.current.value.trim(); + if (message) { + onSendMessage(message); // 부모 컴포넌트에 메시지를 전달 + messageRef.current.value = ''; // 입력창 초기화 + } + } + }; + + return ( + + + + setIsFocused(true)} // 입력창 포커스 시 상태 변경 + onBlur={() => setIsFocused(false)} // 포커스 해제 시 상태 변경 + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSendMessage(); // 엔터 키를 눌렀을 때 메시지 전송 + } + }} + $isFocused={isFocused} + /> + + { + e.preventDefault(); // 기본 동작 방지, 이벤트 버블링 때문에 전송 버튼을 클릭하면 바로 focus가 꺼져서 사라짐 + handleSendMessage(); // 전송 메시지 호출 + }} + /> + + ); +}; + +export default InputBar; + + + diff --git a/src/Pages/ChatRoom/components/InputBar/style.tsx b/src/Pages/ChatRoom/components/InputBar/style.tsx new file mode 100644 index 0000000..70b0d79 --- /dev/null +++ b/src/Pages/ChatRoom/components/InputBar/style.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components'; + +export const InputBarContainer = styled.div` + display: flex; + gap: 14px; + flex-direction: row; + align-items: center; + position: absolute; // 부모 요소에 상대적으로 위치 고정 + bottom: 0; // 부모 요소의 하단에 고정 + left: 0; // 왼쪽도 고정 + right: 0; // 오른쪽도 고정 + padding: 16px; +`; + +export const Camera = styled.img` + display: flex; + width: 24px; + height: 24px; + cursor: pointer; +`; + +export const Gallery = styled.img<{ $isFocused: boolean }>` + display: flex; + width: 24px; + height: 24px; + cursor: pointer; + display: ${({ $isFocused }) => ($isFocused ? 'none' : 'block')}; /* 포커스 시 사라짐 */ +`; + +export const InputField = styled.input<{ $isFocused: boolean }>` + display: flex; + flex: 1; + padding: 12px; + outline: none; + border-radius: 15px; + border: none; + background: var(--gray-scale-100, #E8EBED); + transition: all 0.3s ease; +`; + +export const GoodButton = styled.img<{ $isFocused: boolean }>` + display: flex; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + display: ${({ $isFocused }) => ($isFocused ? 'none' : 'block')}; +`; + +export const SendButton = styled.img<{ $isFocused: boolean }>` + display: flex; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + display: ${({ $isFocused }) => ($isFocused ? 'block' : 'none')}; /* 포커스 시 나타남 */ +`; \ No newline at end of file diff --git a/src/Pages/ChatRoom/components/TopNavBar/index.tsx b/src/Pages/ChatRoom/components/TopNavBar/index.tsx new file mode 100644 index 0000000..cf0b96e --- /dev/null +++ b/src/Pages/ChatRoom/components/TopNavBar/index.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; +import { useNavigate } from 'react-router-dom'; +import phone from '../../../../assets/ChatRoom/phone.svg'; +import Back from '../../../../assets/ChatRoom/BackButton.svg'; +import { userDataState, currentUserIdState, opponentUserIdState } from '../../../../recoil/atom'; +import { TopNavBarContainer, BackIcon, PhoneIcon, ProfileImg, UserInfoText, Name, ActiveStatus } from './style'; + +const TopNavBar: React.FC<{ opponentUserId: number, currentUserId: number }> = ({ opponentUserId, currentUserId }) => { + const navigate = useNavigate(); + const userData = useRecoilValue(userDataState); // atom에서 사용자 데이터 가져오기 + const [, setCurrentUserId] = useRecoilState(currentUserIdState); + const [opponentId, setOpponentUserId] = useRecoilState(opponentUserIdState); + + // 해당 ID에 맞는 사용자 정보 찾기 + const userInfo = userData.find((user) => user.id === Number(opponentId)) || null; + + const handleProfileClick = () => { + setCurrentUserId(opponentUserId); // 현재 사용자를 상대방 ID로 설정 + setOpponentUserId(currentUserId); // 상대방을 현재 사용자 ID로 설정 + }; + + return ( + + navigate(-1)} /> + + + {userInfo?.name} + 현재활동중 + + alert('전화 버튼 클릭됨!')} /> + + ); +}; + +export default TopNavBar; diff --git a/src/Pages/ChatRoom/components/TopNavBar/style.tsx b/src/Pages/ChatRoom/components/TopNavBar/style.tsx new file mode 100644 index 0000000..d184fd6 --- /dev/null +++ b/src/Pages/ChatRoom/components/TopNavBar/style.tsx @@ -0,0 +1,72 @@ +import styled from "styled-components"; + +export const TopNavBarContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + backdrop-filter: blur(15px); /* 배경 흐리게 처리 */ + background-color: rgba(255, 255, 255, 0.5); /* 반투명한 흰색 배경 */ + padding-bottom: 12px; +`; + +export const BackIcon = styled.img` + width: 24px; + height: 24px; + cursor: pointer; + margin-right: 12px; + &:hover { + width: 26px; + height: 26px; + margin-right: 10px; + } +`; + +export const UserInfoText = styled.div` + display: flex; + background-color: white; /* 불투명한 배경 */ + flex-direction: column; /* 세로로 배치 */ + margin-left: 8px; /* 텍스트와 프로필 이미지 사이의 여백 */ +`; + +export const ProfileImg = styled.img` + display: flex; + background-color: white; /* 불투명한 배경 */ + width: 36px; + height: 36px; + cursor: pointer; +`; + +export const Name = styled.div` + font-family: "Noto Sans"; + font-size: 14px; + font-style: normal; + font-weight: 600; + background-color: white; +`; + +export const ActiveStatus = styled.div` + color: var(--gray-scale-200, #C9CDD2); + font-family: "Noto Sans"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: normal; + letter-spacing: -0.18px; +`; + +export const PhoneIcon = styled.img` + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + background: none; + border: none; + cursor: pointer; + + &:hover { + width: 26px; + height: 26px; + } + margin-left: auto; /* 오른쪽 끝으로 이동 */ +`; \ No newline at end of file diff --git a/src/Pages/ChatRoom/components/UserInfo/index.tsx b/src/Pages/ChatRoom/components/UserInfo/index.tsx new file mode 100644 index 0000000..cbc0421 --- /dev/null +++ b/src/Pages/ChatRoom/components/UserInfo/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useRecoilValue } from 'recoil'; +import { userDataState } from '../../../../recoil/atom'; +import { UserInfoContainer, BigProfileImg, Address, StatusDot } from './style'; +import { StatusContainer } from '../../../ChatListPage/components/ActiveStatus/style'; + +const UserInfo: React.FC<{ id: number }> = ({ id }) => { + const userData = useRecoilValue(userDataState); // atom에서 사용자 데이터 가져오기 + + // 해당 ID에 맞는 사용자 정보 찾기 + const userInfo = userData.find((user) => user.id === Number(id)) || null; + + return ( + + + + + +

{userInfo?.name}

+

Facebook 친구입니다

+
서울거주
+
+ ); +}; + +export default UserInfo; + diff --git a/src/Pages/ChatRoom/components/UserInfo/style.tsx b/src/Pages/ChatRoom/components/UserInfo/style.tsx new file mode 100644 index 0000000..6ce3ac2 --- /dev/null +++ b/src/Pages/ChatRoom/components/UserInfo/style.tsx @@ -0,0 +1,32 @@ +import styled from "styled-components"; + +export const UserInfoContainer = styled.div` + display: flex; + margin-bottom: 12px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; +`; + +export const Address = styled.p` + color: var(--gray-scale-400, #9EA4AA); +`; + +export const BigProfileImg = styled.img` + display: flex; + width: 102px; + height: 102px; +`; + +export const StatusDot = styled.div` + position: absolute; + bottom: 1px; /* 이미지 오른쪽 하단에 맞춰서 위치 조정 */ + right: 6px; + width: 25px; + height: 25px; + background-color: #45D658; + border-radius: 50%; + z-index: 2; /* 사진 위에 표시될 수 있도록 z-index 설정 */ + border: 3.5px solid white; +`; \ No newline at end of file diff --git a/src/Pages/ChatRoom/index.tsx b/src/Pages/ChatRoom/index.tsx new file mode 100644 index 0000000..0b95138 --- /dev/null +++ b/src/Pages/ChatRoom/index.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { userDataState, chatDataState, currentUserIdState, opponentUserIdState } from '../../recoil/atom'; +import TopNavBar from './components/TopNavBar'; +import Chats from './components/Chats'; +import InputBar from './components/InputBar'; +import { ChatRoomContainer, ProfileImgSmall } from './styles'; +import StatusBar from '../../components/StatusBar'; +import { MessageProps } from './components/Chats'; + +const ChatRoom: React.FC = () => { + const { id: chatId } = useParams<{ id: string }>(); + const userData = useRecoilValue(userDataState); // atom에서 사용자 데이터 가져오기 + const [chatData, setChatData] = useRecoilState(chatDataState); // atom에서 채팅 데이터 가져오기 및 업데이트하기 + const [messages, setMessages] = useState< MessageProps[]>([]); + const [currentUserId, setCurrentUserId] = useRecoilState(currentUserIdState); + const [opponentUserId, setOpponentUserId] = useRecoilState(opponentUserIdState); + const [opponentProfileImage, setOpponentProfileImage] = useState(null); + const chatRef = React.useRef(null); + const [typingTimeout, setTypingTimeout] = useState(null); + + useEffect(() => { + const initializeChat = () => { + const chatDataForId = chatData[chatId!]; // chatId에 해당하는 대화 데이터 가져오기 + + if (chatDataForId) { + // 현재 사용자 ID와 상대방 사용자 ID 설정 + setCurrentUserId(chatDataForId.users[0].id); // 첫 번째 사용자를 현재 사용자로 설정 + setOpponentUserId(chatDataForId.users[1].id); // 두 번째 사용자를 상대방으로 설정 + setMessages(chatDataForId.messages as MessageProps[]); // 대화 메시지 설정 + } + + // 로컬 스토리지에서 메시지 로드 + const storedMessages = localStorage.getItem(`chatMessages-${chatId}`); + if (storedMessages) { + setMessages(JSON.parse(storedMessages) as MessageProps[]); + } + }; + + if (chatId) { + initializeChat(); + } + }, [chatId, chatData, userData, setCurrentUserId, setOpponentUserId]); // 의존성 배열에 chatData와 userData 추가 + + useEffect(() => { + const opponentUser = userData.find((user) => user.id === opponentUserId); + setOpponentProfileImage(opponentUser ? opponentUser.profileImage : null); + }, [opponentUserId, userData]); + + useEffect(() => { + // 새 메시지가 추가되면 스크롤을 맨 아래로 이동 + if (chatRef.current) { + chatRef.current.scrollTop = chatRef.current.scrollHeight; + } + }, [messages]); + + const handleSendMessage = (message: string) => { + const newMessage:MessageProps = { userId: currentUserId, content: message, time: new Date().toISOString() }; + const updatedMessages:MessageProps[] = [...messages, newMessage]; + setMessages(updatedMessages); + + // 로컬 스토리지에 저장 + localStorage.setItem(`chatMessages-${chatId}`, JSON.stringify(updatedMessages)); + + // Recoil 상태 업데이트 + setChatData((prevChatData) => ({ + ...prevChatData, + [chatId!]: { + ...prevChatData[chatId!], + messages: updatedMessages, + }, + })); + + // 상대방의 자동 답장 로직 + if (typingTimeout) { + clearTimeout(typingTimeout); + } + + const timeoutId = setTimeout(() => { + const receivedMessage1: MessageProps = { userId: opponentUserId, content: "세오스 20기", time: new Date().toISOString() }; + const updatedMessagesWithFirstResponse: MessageProps[] = [...updatedMessages, receivedMessage1]; + setMessages(updatedMessagesWithFirstResponse); + + // 로컬 스토리지와 Recoil 상태에 저장 + localStorage.setItem(`chatMessages-${chatId}`, JSON.stringify(updatedMessagesWithFirstResponse)); + setChatData((prevChatData) => ({ + ...prevChatData, + [chatId!]: { + ...prevChatData[chatId!], + messages: updatedMessagesWithFirstResponse, + }, + })); + + setTimeout(() => { + const receivedMessage2:MessageProps = { userId: opponentUserId, content: "FE 파이팅 🩷🩷", time: new Date().toISOString() }; + const updatedMessagesWithSecondResponse: MessageProps[] = [...updatedMessagesWithFirstResponse, receivedMessage2]; + setMessages(updatedMessagesWithSecondResponse); + + // 로컬 스토리지와 Recoil 상태에 최종 메시지 저장 + localStorage.setItem(`chatMessages-${chatId}`, JSON.stringify(updatedMessagesWithSecondResponse)); + setChatData((prevChatData) => ({ + ...prevChatData, + [chatId!]: { + ...prevChatData[chatId!], + messages: updatedMessagesWithSecondResponse, + }, + })); + }, 2000); + }, 2000); + + setTypingTimeout(timeoutId); + }; + + return ( + + + + { + // 메시지 배열의 길이를 체크하여 유효한 인덱스인지 확인 + if (index < 0 || index >= messages.length) { + return null; // 유효하지 않은 인덱스일 경우 null 반환 + } + const isLastMessage = messages[index].userId === opponentUserId && + (index === messages.length - 1 || messages[index + 1]?.userId === currentUserId); + + return isLastMessage ? ( + + ) : null; + }} + /> + + + ); +}; + +export default ChatRoom; + + + + + + + + + + diff --git a/src/Pages/ChatRoom/styles.tsx b/src/Pages/ChatRoom/styles.tsx new file mode 100644 index 0000000..7695fe0 --- /dev/null +++ b/src/Pages/ChatRoom/styles.tsx @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +export const ChatRoomContainer = styled.div` + display: flex; + padding: 0 16px; + height: 100%; + flex-direction: column; + position: relative; + overflow: auto; // 내부 요소가 넘칠 경우 스크롤 가능 +`; +export const ProfileImgSmall = styled.img` + width: 24px; + height: 24px; + margin: 5px 8px 0 0; +`; + \ No newline at end of file diff --git a/src/Pages/FriendListPage/FriendList/index.tsx b/src/Pages/FriendListPage/FriendList/index.tsx new file mode 100644 index 0000000..0094faf --- /dev/null +++ b/src/Pages/FriendListPage/FriendList/index.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { ListContainer, Count, FriendItem, FriendName, ProfileImg, NoResult } from "./style"; +import phone from '../../../assets/FriendListPage/blue-phone.svg'; +import { PhoneIcon } from "../../ChatRoom/components/TopNavBar/style"; +import { useRecoilValue } from 'recoil'; +import { userDataState } from "../../../recoil/atom"; + +export interface User { + id: number; + name: string; + profileImage: string; +} + +export interface SearchListProps { + searchTerm: string; +} + +const FriendList: React.FC = ({ searchTerm }) => { + const userData = useRecoilValue(userDataState); // atom에서 사용자 데이터 가져오기 + const navigate = useNavigate(); + + // 검색어에 따라 친구 목록 필터링 (userId가 5인 사용자, 진나경을 제외함) + const filteredUsers = userData.filter((user: User) => + user.id !== 5 && user.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const handleFriendClick = (id: number) => { + navigate(`/chat/${id}`); // 친구 id를 URL에 포함하여 대화 페이지로 이동 + }; + + const handlePhoneClick = (event: React.MouseEvent) => { + event.stopPropagation(); // 이벤트 전파를 막아 FriendItem의 onClick이 실행되지 않도록 함 + }; + + return ( + + 친구 {filteredUsers.length}명 + {filteredUsers.length > 0 ? ( + filteredUsers.map((user: User) => ( + handleFriendClick(user.id)}> + + {user.name} + + + )) + ) : ( + 검색 결과가 없습니다. 🥹 + )} + + ); +}; + +export default FriendList; + diff --git a/src/Pages/FriendListPage/FriendList/style.tsx b/src/Pages/FriendListPage/FriendList/style.tsx new file mode 100644 index 0000000..201d27b --- /dev/null +++ b/src/Pages/FriendListPage/FriendList/style.tsx @@ -0,0 +1,39 @@ +import styled from "styled-components"; + +export const ListContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const Count = styled.span` + margin-top: 22px; + color: #C9CDD2; + font-size: 12px; + font-style: normal; + font-weight: 400; +`; +export const FriendItem = styled.div` + display: flex; + align-items: center; + cursor: pointer; + gap: 12px; + padding: 14px 0; +`; + +export const ProfileImg = styled.img` + border-radius: 50%; +`; + +export const FriendName = styled.span` + color: #454C53; + font-size: 16px; + font-style: normal; + font-weight: 600; +`; + +export const NoResult = styled.div` + margin-top: 20px; + text-align: center; + font-size: 16px; + font-style: normal; +`; \ No newline at end of file diff --git a/src/Pages/FriendListPage/index.tsx b/src/Pages/FriendListPage/index.tsx new file mode 100644 index 0000000..5f5becd --- /dev/null +++ b/src/Pages/FriendListPage/index.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; +import FriendList from './FriendList'; +import { useRecoilState } from 'recoil'; +import { chatDataState, userDataState } from '../../recoil/atom'; +import { PageContainer } from './style'; +import BottomNav from '../../components/BottomNav'; +import Header from '../../components/Header'; +import StatusBar from '../../components/StatusBar'; + +//사실상 Home 페이지! +const FriendListPage: React.FC = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [, setUserData] = useRecoilState(userDataState); + const [, setChatData] = useRecoilState(chatDataState); + + useEffect(() => { + const loadUserData = async () => { + try { + const response = await fetch('/mockUserData.json'); + const userData = await response.json(); + setUserData(userData); // userData를 atom에 저장 + } catch (error) { + console.error("Error fetching user data:", error); + } + }; + + const loadChatData = async () => { + try { + const response = await fetch('/mockChatData.json'); + const chatData = await response.json(); + + // 로컬스토리지에서 업데이트된 메시지 병합 + Object.keys(chatData.chatMessages).forEach((chatId) => { + const storedMessages = localStorage.getItem(`chatMessages-${chatId}`); + if (storedMessages) { + chatData.chatMessages[chatId].messages = JSON.parse(storedMessages); + } + }); + + setChatData(chatData.chatMessages); // chatData를 atom에 저장 + } catch (error) { + console.error("Error fetching chat data:", error); + } + }; + + loadUserData(); + loadChatData(); + }, [setUserData, setChatData]); + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + return ( + + +
+ + + + ); +}; + +export default FriendListPage; + diff --git a/src/Pages/FriendListPage/style.tsx b/src/Pages/FriendListPage/style.tsx new file mode 100644 index 0000000..bc414a5 --- /dev/null +++ b/src/Pages/FriendListPage/style.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const PageContainer = styled.div` + display: flex; + flex-direction: column; + padding: 0 16px; + position: relative; + min-height: 100%; +`; diff --git a/src/Pages/StoryPage/LoadingSpinner/index.tsx b/src/Pages/StoryPage/LoadingSpinner/index.tsx new file mode 100644 index 0000000..0037ac8 --- /dev/null +++ b/src/Pages/StoryPage/LoadingSpinner/index.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { LoadingContainer, DotContainer, Dot } from "./style"; + +const LoadingSpinner: React.FC = () => { + return ( + + 서비스 준비 중입니다!🩵 + + . + . + . + + + ); +}; + +export default LoadingSpinner; diff --git a/src/Pages/StoryPage/LoadingSpinner/style.tsx b/src/Pages/StoryPage/LoadingSpinner/style.tsx new file mode 100644 index 0000000..66b1581 --- /dev/null +++ b/src/Pages/StoryPage/LoadingSpinner/style.tsx @@ -0,0 +1,47 @@ +import styled, { keyframes } from "styled-components"; + +export const dotsAnimation = keyframes` + 0%, 20% { + opacity: 0.1; + } + 40% { + opacity: 1; + } + 60% { + opacity: 0.1; + } + 100% { + opacity: 0.1; + } +`; + +export const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + font-size: 16px; + color: #333; + margin-top: 100px; +`; + +export const DotContainer = styled.div` + display: flex; + justify-content: center; +`; + +export const Dot = styled.span` + display: flex; + margin: 0 2px; + font-size: 50px; + animation: ${dotsAnimation} 1s infinite; + + &:nth-child(1) { + animation-delay: 0s; + } + &:nth-child(2) { + animation-delay: 0.2s; + } + &:nth-child(3) { + animation-delay: 0.4s; + } +`; \ No newline at end of file diff --git a/src/Pages/StoryPage/index.tsx b/src/Pages/StoryPage/index.tsx new file mode 100644 index 0000000..3d57160 --- /dev/null +++ b/src/Pages/StoryPage/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { PageContainer } from "../FriendListPage/style"; +import BottomNav from "../../components/BottomNav"; +import LoadingSpinner from "./LoadingSpinner"; + +const StoryPage: React.FC = () => { + return ( + + + + + ); + }; + + export default StoryPage; \ No newline at end of file diff --git a/src/assets/ChatRoom/BackButton.svg b/src/assets/ChatRoom/BackButton.svg new file mode 100644 index 0000000..f6d6155 --- /dev/null +++ b/src/assets/ChatRoom/BackButton.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/ChatRoom/BigProfileImg.svg b/src/assets/ChatRoom/BigProfileImg.svg new file mode 100644 index 0000000..e894b19 --- /dev/null +++ b/src/assets/ChatRoom/BigProfileImg.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/ChatRoom/BigProfileImg2.svg b/src/assets/ChatRoom/BigProfileImg2.svg new file mode 100644 index 0000000..94344be --- /dev/null +++ b/src/assets/ChatRoom/BigProfileImg2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/ChatRoom/Camera.svg b/src/assets/ChatRoom/Camera.svg new file mode 100644 index 0000000..fb2474d --- /dev/null +++ b/src/assets/ChatRoom/Camera.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/ChatRoom/Gallery.svg b/src/assets/ChatRoom/Gallery.svg new file mode 100644 index 0000000..1b01876 --- /dev/null +++ b/src/assets/ChatRoom/Gallery.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/ChatRoom/Good.svg b/src/assets/ChatRoom/Good.svg new file mode 100644 index 0000000..552c00a --- /dev/null +++ b/src/assets/ChatRoom/Good.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/ChatRoom/SendIcon.svg b/src/assets/ChatRoom/SendIcon.svg new file mode 100644 index 0000000..31a73df --- /dev/null +++ b/src/assets/ChatRoom/SendIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/ChatRoom/cat.svg b/src/assets/ChatRoom/cat.svg new file mode 100644 index 0000000..4d0d8c4 --- /dev/null +++ b/src/assets/ChatRoom/cat.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/ChatRoom/phone.svg b/src/assets/ChatRoom/phone.svg new file mode 100644 index 0000000..9b483fb --- /dev/null +++ b/src/assets/ChatRoom/phone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/ChatRoom/profile.svg b/src/assets/ChatRoom/profile.svg new file mode 100644 index 0000000..0d9168b --- /dev/null +++ b/src/assets/ChatRoom/profile.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/FriendListPage/ChatIcon.tsx b/src/assets/FriendListPage/ChatIcon.tsx new file mode 100644 index 0000000..26a7445 --- /dev/null +++ b/src/assets/FriendListPage/ChatIcon.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { IconProps } from "./IconProps"; + +const ChatIcon: React.FC = ({ color = "#72787F" }) => ( + + + + + +); + +export default ChatIcon; diff --git a/src/assets/FriendListPage/FriendIcon.tsx b/src/assets/FriendListPage/FriendIcon.tsx new file mode 100644 index 0000000..c0d1cb3 --- /dev/null +++ b/src/assets/FriendListPage/FriendIcon.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { IconProps } from "./IconProps"; + +const FriendIcon: React.FC = ({ color = "#72787F" }) => ( + + + +); + +export default FriendIcon; + diff --git a/src/assets/FriendListPage/IconProps.ts b/src/assets/FriendListPage/IconProps.ts new file mode 100644 index 0000000..6d24669 --- /dev/null +++ b/src/assets/FriendListPage/IconProps.ts @@ -0,0 +1,3 @@ +export interface IconProps { + color?: string; // fill 색상을 props로 받을 수 있도록 설정 + } \ No newline at end of file diff --git a/src/assets/FriendListPage/Mobile Signal.svg b/src/assets/FriendListPage/Mobile Signal.svg new file mode 100644 index 0000000..6744833 --- /dev/null +++ b/src/assets/FriendListPage/Mobile Signal.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/FriendListPage/StoryIcon.tsx b/src/assets/FriendListPage/StoryIcon.tsx new file mode 100644 index 0000000..8d490ad --- /dev/null +++ b/src/assets/FriendListPage/StoryIcon.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { IconProps } from "./IconProps"; + +const StroyIcon: React.FC = ({ color = "#72787F" }) => ( + + + + + + +); + +export default StroyIcon; diff --git a/src/assets/FriendListPage/Wifi.svg b/src/assets/FriendListPage/Wifi.svg new file mode 100644 index 0000000..a1d5714 --- /dev/null +++ b/src/assets/FriendListPage/Wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/FriendListPage/_StatusBar-battery.svg b/src/assets/FriendListPage/_StatusBar-battery.svg new file mode 100644 index 0000000..2562947 --- /dev/null +++ b/src/assets/FriendListPage/_StatusBar-battery.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/FriendListPage/blue-phone.svg b/src/assets/FriendListPage/blue-phone.svg new file mode 100644 index 0000000..39ec4bb --- /dev/null +++ b/src/assets/FriendListPage/blue-phone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/BottomNav/index.tsx b/src/components/BottomNav/index.tsx new file mode 100644 index 0000000..88c3e90 --- /dev/null +++ b/src/components/BottomNav/index.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useLocation } from "react-router-dom"; +import { BottomNavContainer, NavItem, Menu, MenuLayout, HomeIndicator } from "./style"; +import { Link } from "react-router-dom"; +import FriendIcon from '../../assets/FriendListPage/FriendIcon'; +import ChatIcon from '../../assets/FriendListPage/ChatIcon'; +import StroyIcon from '../../assets/FriendListPage/StoryIcon'; + +const BottomNav: React.FC = () => { + const location = useLocation(); + + return ( + + + + {/* 경로에 따라 색상 변경 */} + 친구 + + + {/* 경로에 따라 색상 변경 */} + 채팅 + + + {/* 스토리 아이콘은 항상 회색으로 설정 */} + 스토리 + + + + + ); +}; + +export default BottomNav; diff --git a/src/components/BottomNav/style.tsx b/src/components/BottomNav/style.tsx new file mode 100644 index 0000000..fbec39b --- /dev/null +++ b/src/components/BottomNav/style.tsx @@ -0,0 +1,61 @@ +import styled from "styled-components"; + +interface NavItemProps { + $active: boolean; +} + +export const BottomNavContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const MenuLayout = styled.div` + display: flex; + width: 100%; + gap: 76px; + align-items: center; + justify-content: center; + padding: 12px 0 40px 0 ; + position: absolute; /* 부모 요소를 기준으로 하단에 고정 */ + bottom: 0; /* 부모 요소의 가장 아래에 위치 */ + left: 0; + background-color: #F7F8F9; /* 배경 색상 */ + border-radius: 0 0 40px 40px; +`; + +export const NavItem = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + cursor: pointer; /* 커서 모양 */ + text-decoration: none; + color: ${(props) => (props.$active ? "#1675FF" : "#72787F")}; /* $active 상태에 따른 색상 */ +`; + +export const Menu = styled.span` + color: ${(props) => props.color || "#72787F"}; + font-size: 12px; + font-style: normal; + font-weight: 500; + + &:hover { + color: #1675FF; /* 마우스 오버 시 색상 변경 */ + } +`; + +export const Icon = styled.img` + display: flex; + width: 24px; + height: 24px; +`; + +export const HomeIndicator = styled.div` + width: 139px; + height: 5px; + border-radius: 100px; + background: var(--gray-scale-800, #26282B); + position: absolute; /* 부모 요소를 기준으로 하단에 고정 */ + bottom: 10px; /* 부모 요소의 가장 아래에 위치 */ +`; \ No newline at end of file diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000..3115e73 --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1,38 @@ +import React, { useState } from "react"; +import { HeaderContainer, ProfileImg, Title, FriendSearch } from "./style"; +import profileImg from '../../assets/ChatRoom/cat.svg'; +import Sidebar from "../Sidebar"; + +interface HeaderProps { + searchTerm: string; + onSearchChange: (event: React.ChangeEvent) => void; + title: string; +} + +const Header: React.FC = ({ searchTerm, onSearchChange, title }) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const toggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; + + const closeSidebar = () => { + setIsSidebarOpen(false); + }; + + return ( + <> + + + {title} + + + {isSidebarOpen && } {/* 사이드바 조건부 렌더링 */} + + ); +}; + +export default Header; \ No newline at end of file diff --git a/src/components/Header/style.tsx b/src/components/Header/style.tsx new file mode 100644 index 0000000..0a0eeac --- /dev/null +++ b/src/components/Header/style.tsx @@ -0,0 +1,32 @@ +import styled from "styled-components"; + +export const HeaderContainer = styled.header` + display: flex; + gap: 12px; + align-items: center; + margin: 0 0 12px 0; +`; + +export const Title = styled.p` + display: flex; + font-size: 20px; + font-style: normal; + font-weight: 600; +`; + +export const ProfileImg = styled.img` + display: flex; +`; + +export const FriendSearch = styled.input` + display: flex; + width: 100%; + max-width: 343px; + height: 34px; + border: none; + outline:none; + padding-left: 12px; + border-radius: 8px; + caret-color: #1675FF; + background-color: #F7F8F9; +`; \ No newline at end of file diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx new file mode 100644 index 0000000..68755d9 --- /dev/null +++ b/src/components/Sidebar/index.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import BigImg from '../../assets/ChatRoom/BigProfileImg2.svg'; +import { SidebarContainer, Overlay, MyProfile, Photo, Name, Title, List } from "./style"; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; +} + +const Sidebar: React.FC = ({ isOpen, onClose }) => { + return ( + <> + {/* Overlay 클릭 시 사이드바 닫기 */} + {/* isOpen을 $isOpen으로 변경 */} + 내 프로필 + + + 진나경 + + Chat + + Instagram + + + + ); +}; + +export default Sidebar; + diff --git a/src/components/Sidebar/style.tsx b/src/components/Sidebar/style.tsx new file mode 100644 index 0000000..6641c40 --- /dev/null +++ b/src/components/Sidebar/style.tsx @@ -0,0 +1,76 @@ +import styled from "styled-components"; + +interface SidebarContainerProps { + $isOpen: boolean; // 사이드바의 열림 상태를 나타내는 prop +} + +export const Overlay = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.1); // 반투명 배경 + backdrop-filter: blur(2px); // 블러 효과 + z-index: 500; // 사이드바보다 아래에 표시되도록 + border-radius: 40px; +`; + +export const SidebarContainer = styled.div` + display: flex; + flex-direction: column; + position: absolute; // 부모 요소에 상대적으로 위치 + top: 0; // 부모 요소의 위쪽에 맞추기 + left: 0; // 부모 요소의 왼쪽에 맞추기 + width: 250px; // 사이드바 너비 + height: 100%; // 부모 요소의 높이에 맞추기 + background-color: white; // 배경 색상 + z-index: 1000; // 다른 요소 위에 표시 + border-radius: 40px 0 0 40px; + color: black; + padding: 0 16px; + transition: transform 0.5s ease; + transform: ${({ $isOpen }) => ($isOpen ? 'translateX(0)' : 'translateX(-100%)')}; +`; + +export const MyProfile = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin-top: 12px; +`; + +export const Title = styled.div` + margin-top: 60px; + font-size: 20px; + font-style: normal; + font-weight: 600; +`; + +export const Photo = styled.img` + display: flex; + width: 102px; + height: 102px; +`; + +export const Name = styled.div` + display: flex; + font-size: 16px; + font-style: normal; + font-weight: 600; + margin-bottom: 40px; +`; + +export const List = styled.div` + display: flex; + font-size: 16px; + font-style: normal; + text-decoration: none; // 밑줄 제거 + padding: 6px 12px; + color: black; + cursor: pointer; // 커서 포인터로 변경 + &:hover { + color: #1675FF; + } +`; \ No newline at end of file diff --git a/src/components/StatusBar/index.tsx b/src/components/StatusBar/index.tsx new file mode 100644 index 0000000..2d375d9 --- /dev/null +++ b/src/components/StatusBar/index.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { StatusBarContainer, Time, NetWorkwrapper } from "./style"; +import MobileSignal from "../../assets/FriendListPage/Mobile Signal.svg" +import Wifi from "../../assets/FriendListPage/Wifi.svg"; +import Battery from "../../assets/FriendListPage/_StatusBar-battery.svg"; + +const StatusBar: React.FC = () => { + return( + + + + MobileSignal + Wifi + StatusBar + + + ); +} + +export default StatusBar; \ No newline at end of file diff --git a/src/components/StatusBar/style.tsx b/src/components/StatusBar/style.tsx new file mode 100644 index 0000000..cb48026 --- /dev/null +++ b/src/components/StatusBar/style.tsx @@ -0,0 +1,21 @@ +import styled from "styled-components"; + +export const StatusBarContainer = styled.div` + display: flex; + justify-content: space-between; // 양쪽 끝으로 배치 + align-items: center; // 세로 중앙 정렬 + width: 100%; // 가로 전체를 사용 + padding: 18px 5px 18px 15px; +`; + +export const Time = styled.div` + display: flex; + font-size: 16.346px; + font-style: normal; + font-weight: 500; +`; + +export const NetWorkwrapper = styled.div` + display: flex; + gap: 7px; +`; \ No newline at end of file diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 0000000..f98eb76 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: string; + export default content; + } \ No newline at end of file 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..3b19316 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import './index.css'; import App from './App'; const root = ReactDOM.createRoot( diff --git a/src/recoil/atom.ts b/src/recoil/atom.ts new file mode 100644 index 0000000..f9edf76 --- /dev/null +++ b/src/recoil/atom.ts @@ -0,0 +1,71 @@ +import { atom } from 'recoil'; + +interface User { + id: number; + name: string; + profileImage: string; +} + +interface ChatMessage { + userId: number; + content: string; + time: string; +} + +// 로컬 스토리지에서 데이터 불러오기 유틸 함수 +const loadFromLocalStorage = (key: string, defaultValue: T): T => { + const storedValue = localStorage.getItem(key); + return storedValue ? JSON.parse(storedValue) : defaultValue; +}; + +// 사용자 데이터 상태 +export const userDataState = atom({ + key: 'userDataState', + default: loadFromLocalStorage('userData', []), // 로컬 스토리지에서 초기 데이터 불러오기 + effects: [ + ({ onSet }) => { + onSet(newValue => { + localStorage.setItem('userData', JSON.stringify(newValue)); // 상태가 변경될 때 로컬 스토리지에 저장 + }); + }, + ], +}); + +// 채팅 데이터 상태 +export const chatDataState = atom<{ [key: string]: { messages: ChatMessage[]; users: User[] } }>({ + key: 'chatDataState', + default: loadFromLocalStorage<{ [key: string]: { messages: ChatMessage[]; users: User[] } }>('chatData', {}), // 로컬 스토리지에서 초기 데이터 불러오기 + effects: [ + ({ onSet }) => { + onSet(newValue => { + localStorage.setItem('chatData', JSON.stringify(newValue)); // 상태가 변경될 때 로컬 스토리지에 저장 + }); + }, + ], +}); + +// 현재 사용자 ID 상태 +export const currentUserIdState = atom({ + key: 'currentUserIdState', + default: loadFromLocalStorage('currentUserId', 0), // 로컬 스토리지에서 초기값 불러오기 + effects: [ + ({ onSet }) => { + onSet(newValue => { + localStorage.setItem('currentUserId', JSON.stringify(newValue)); // 변경된 ID 로컬 스토리지에 저장 + }); + }, + ], +}); + +// 상대 사용자 ID 상태 +export const opponentUserIdState = atom({ + key: 'opponentUserIdState', + default: loadFromLocalStorage('opponentUserId', 0), // 로컬 스토리지에서 초기값 불러오기 + effects: [ + ({ onSet }) => { + onSet(newValue => { + localStorage.setItem('opponentUserId', JSON.stringify(newValue)); // 변경된 ID 로컬 스토리지에 저장 + }); + }, + ], +});