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(
+
+
+
+
+
+
+
+
+ );
+}
+
+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 로컬 스토리지에 저장
+ });
+ },
+ ],
+});