diff --git a/package-lock.json b/package-lock.json index c27bbe4..2f683f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "react-messenger-19th", "version": "0.1.0", "dependencies": { + "@tailwindcss/line-clamp": "^0.4.4", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -17,9 +18,14 @@ "@types/react-dom": "^18.2.22", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.27.0", "react-scripts": "5.0.1", + "tailwind-scrollbar-hide": "^1.1.7", "typescript": "^4.9.5", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "tailwindcss": "^3.4.13" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3338,6 +3344,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "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", @@ -3656,6 +3670,14 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tailwindcss/line-clamp": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz", + "integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==", + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -14864,6 +14886,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "dependencies": { + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "dependencies": { + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -16490,10 +16542,15 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "node_modules/tailwind-scrollbar-hide": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz", + "integrity": "sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==" + }, "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -16503,7 +16560,7 @@ "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.19.1", + "jiti": "^1.21.0", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", diff --git a/package.json b/package.json index ea335d3..3776c08 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@tailwindcss/line-clamp": "^0.4.4", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -12,7 +13,9 @@ "@types/react-dom": "^18.2.22", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.27.0", "react-scripts": "5.0.1", + "tailwind-scrollbar-hide": "^1.1.7", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, @@ -39,5 +42,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "tailwindcss": "^3.4.13" } } diff --git a/src/App.tsx b/src/App.tsx index 5381007..d70c7c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,33 @@ +import ChatRoomPage from './pages/ChatRoomPage'; +import ChatRoomListPage from './pages/ChatRoomListPage'; +import FriendListPage from './pages/FriendListPage'; +import { Route, Routes } from 'react-router-dom'; +import MyProfilePage from './pages/MyProfilePage'; +import NotYetPage from './pages/NotYetPage'; +import { UserProvider } from './contexts/UserContext'; +import { UnreadProvider } from './contexts/UnreadContext'; + function App() { return ( -
-

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

-
+ + +
+
+ + }> + }> + }> + + }> + }> + }> + }> + }> + +
+
+
+
); } diff --git a/src/assets/ChatRoom/addMedia.svg b/src/assets/ChatRoom/addMedia.svg new file mode 100644 index 0000000..41f4f68 --- /dev/null +++ b/src/assets/ChatRoom/addMedia.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/ChatRoom/back.svg b/src/assets/ChatRoom/back.svg new file mode 100644 index 0000000..18eacf2 --- /dev/null +++ b/src/assets/ChatRoom/back.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/ChatRoom/emoticonbtn.svg b/src/assets/ChatRoom/emoticonbtn.svg new file mode 100644 index 0000000..ff6969f --- /dev/null +++ b/src/assets/ChatRoom/emoticonbtn.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/ChatRoom/menu.svg b/src/assets/ChatRoom/menu.svg new file mode 100644 index 0000000..674a0c6 --- /dev/null +++ b/src/assets/ChatRoom/menu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/ChatRoom/profile.svg b/src/assets/ChatRoom/profile.svg new file mode 100644 index 0000000..33c587a --- /dev/null +++ b/src/assets/ChatRoom/profile.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/ChatRoom/search.svg b/src/assets/ChatRoom/search.svg new file mode 100644 index 0000000..7fe66cc --- /dev/null +++ b/src/assets/ChatRoom/search.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/ChatRoom/sendbtn.svg b/src/assets/ChatRoom/sendbtn.svg new file mode 100644 index 0000000..03248de --- /dev/null +++ b/src/assets/ChatRoom/sendbtn.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/ChatRoomList/chatting_icon.svg b/src/assets/ChatRoomList/chatting_icon.svg new file mode 100644 index 0000000..4788288 --- /dev/null +++ b/src/assets/ChatRoomList/chatting_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/ChatRoomList/friend_icon.svg b/src/assets/ChatRoomList/friend_icon.svg new file mode 100644 index 0000000..60f73b5 --- /dev/null +++ b/src/assets/ChatRoomList/friend_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/ChatRoomList/more_icon.svg b/src/assets/ChatRoomList/more_icon.svg new file mode 100644 index 0000000..e97e941 --- /dev/null +++ b/src/assets/ChatRoomList/more_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/ChatRoomList/newChat_icon.svg b/src/assets/ChatRoomList/newChat_icon.svg new file mode 100644 index 0000000..59338f6 --- /dev/null +++ b/src/assets/ChatRoomList/newChat_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/ChatRoomList/openedChat_icon.svg b/src/assets/ChatRoomList/openedChat_icon.svg new file mode 100644 index 0000000..a43f2f6 --- /dev/null +++ b/src/assets/ChatRoomList/openedChat_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/ChatRoomList/setting_icon.svg b/src/assets/ChatRoomList/setting_icon.svg new file mode 100644 index 0000000..e0b5409 --- /dev/null +++ b/src/assets/ChatRoomList/setting_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/ChatRoomList/shopping_icon.svg b/src/assets/ChatRoomList/shopping_icon.svg new file mode 100644 index 0000000..f79754c --- /dev/null +++ b/src/assets/ChatRoomList/shopping_icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/Common/HomeIndicator.svg b/src/assets/Common/HomeIndicator.svg new file mode 100644 index 0000000..039e582 --- /dev/null +++ b/src/assets/Common/HomeIndicator.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/Common/shield_warning.svg b/src/assets/Common/shield_warning.svg new file mode 100644 index 0000000..75bb670 --- /dev/null +++ b/src/assets/Common/shield_warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/FriendList/down_arrow.svg b/src/assets/FriendList/down_arrow.svg new file mode 100644 index 0000000..e85132c --- /dev/null +++ b/src/assets/FriendList/down_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/FriendList/make_peong.svg b/src/assets/FriendList/make_peong.svg new file mode 100644 index 0000000..d19d2f5 --- /dev/null +++ b/src/assets/FriendList/make_peong.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/FriendList/peong_bg.svg b/src/assets/FriendList/peong_bg.svg new file mode 100644 index 0000000..019a36a --- /dev/null +++ b/src/assets/FriendList/peong_bg.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/FriendList/redDot.svg b/src/assets/FriendList/redDot.svg new file mode 100644 index 0000000..f691dab --- /dev/null +++ b/src/assets/FriendList/redDot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/FriendList/up_arrow.svg b/src/assets/FriendList/up_arrow.svg new file mode 100644 index 0000000..f3d9906 --- /dev/null +++ b/src/assets/FriendList/up_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/MyProfile/add_friend.svg b/src/assets/MyProfile/add_friend.svg new file mode 100644 index 0000000..c081114 --- /dev/null +++ b/src/assets/MyProfile/add_friend.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/MyProfile/multiProfile.svg b/src/assets/MyProfile/multiProfile.svg new file mode 100644 index 0000000..83635ac --- /dev/null +++ b/src/assets/MyProfile/multiProfile.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/MyProfile/music.svg b/src/assets/MyProfile/music.svg new file mode 100644 index 0000000..8d0fb6c --- /dev/null +++ b/src/assets/MyProfile/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/MyProfile/my_profile.svg b/src/assets/MyProfile/my_profile.svg new file mode 100644 index 0000000..b8403fe --- /dev/null +++ b/src/assets/MyProfile/my_profile.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/MyProfile/right_arrow.svg b/src/assets/MyProfile/right_arrow.svg new file mode 100644 index 0000000..d84c817 --- /dev/null +++ b/src/assets/MyProfile/right_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/Reactions/angry.svg b/src/assets/Reactions/angry.svg new file mode 100644 index 0000000..78033c9 --- /dev/null +++ b/src/assets/Reactions/angry.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/Reactions/heart.svg b/src/assets/Reactions/heart.svg new file mode 100644 index 0000000..3496d30 --- /dev/null +++ b/src/assets/Reactions/heart.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/Reactions/oh.svg b/src/assets/Reactions/oh.svg new file mode 100644 index 0000000..c92037b --- /dev/null +++ b/src/assets/Reactions/oh.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/Reactions/plus.svg b/src/assets/Reactions/plus.svg new file mode 100644 index 0000000..80c523f --- /dev/null +++ b/src/assets/Reactions/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/Reactions/smile.svg b/src/assets/Reactions/smile.svg new file mode 100644 index 0000000..277eead --- /dev/null +++ b/src/assets/Reactions/smile.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/Reactions/tear.svg b/src/assets/Reactions/tear.svg new file mode 100644 index 0000000..eafd494 --- /dev/null +++ b/src/assets/Reactions/tear.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/Reactions/thumb.svg b/src/assets/Reactions/thumb.svg new file mode 100644 index 0000000..99122a7 --- /dev/null +++ b/src/assets/Reactions/thumb.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/TopBar/Battery.svg b/src/assets/TopBar/Battery.svg new file mode 100644 index 0000000..2096fbf --- /dev/null +++ b/src/assets/TopBar/Battery.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/TopBar/CellularConnection.svg b/src/assets/TopBar/CellularConnection.svg new file mode 100644 index 0000000..71c8333 --- /dev/null +++ b/src/assets/TopBar/CellularConnection.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/TopBar/Wifi.svg b/src/assets/TopBar/Wifi.svg new file mode 100644 index 0000000..8fb9f26 --- /dev/null +++ b/src/assets/TopBar/Wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/ChatRoomListPage/ChatRoomComponent.tsx b/src/components/ChatRoomListPage/ChatRoomComponent.tsx new file mode 100644 index 0000000..d0f0b28 --- /dev/null +++ b/src/components/ChatRoomListPage/ChatRoomComponent.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import profileIcon from "../../assets/ChatRoom/profile.svg"; +import { UserData } from '../../lib/UserData'; +import { formatTimeForChatList } from '../../utils/ClockUtils'; + +interface LastMessage { + text: string; + timestamp: Date; +} + +interface ComponentProps { + userId: number; + lastMessage?: LastMessage; + onClick: () => void; + unread: number; +} + +const ChatRoomComponent: React.FC = ({ userId, lastMessage, onClick, unread }) => { + const user = UserData.find(user => user.userId === userId); + + return ( +
+ +
+ {user?.userName || '(알 수 없음)'} + {lastMessage?.text} +
+
+ {lastMessage ? formatTimeForChatList(lastMessage.timestamp) : ''} + {unread > 0 && ( + + {unread} + + )} +
+
+ ); +} + +export default ChatRoomComponent; \ No newline at end of file diff --git a/src/components/ChatRoomListPage/Header.tsx b/src/components/ChatRoomListPage/Header.tsx new file mode 100644 index 0000000..7edbc05 --- /dev/null +++ b/src/components/ChatRoomListPage/Header.tsx @@ -0,0 +1,25 @@ +import searchIcon from "../../assets/ChatRoom/search.svg"; +import menuIcon from "../../assets/ChatRoom/menu.svg"; +import newChatIcon from "../../assets/ChatRoomList/newChat_icon.svg"; +import settingIcon from "../../assets/ChatRoomList/setting_icon.svg"; + +const Header = () => { + + return ( +
+
+

+ 채팅 +

+
+ +
+ Search + NewChat + Setting +
+
+ ); +} + +export default Header; \ No newline at end of file diff --git a/src/components/ChatRoomPage/ChatBar.tsx b/src/components/ChatRoomPage/ChatBar.tsx new file mode 100644 index 0000000..5c5da20 --- /dev/null +++ b/src/components/ChatRoomPage/ChatBar.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import addIcon from "../../assets/ChatRoom/addMedia.svg"; +import emoticon from "../../assets/ChatRoom/emoticonbtn.svg"; +import sendIcon from "../../assets/ChatRoom/sendbtn.svg"; + +const ChatBar = ({ onSendMessage }: { onSendMessage: (message: string) => void }) => { + const [message, setMessage] = useState(''); + const [isInputFocused, setIsInputFocused] = useState(false); + + // 메시지 전송 핸들러 + const handleSendMessage = () => { + if (message.trim() !== '') { + onSendMessage(message); + setMessage(''); + setIsInputFocused(false); + } + }; + + // 엔터로 메시지 전송 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSendMessage(); + } + }; + + return ( +
+ addMedia +
+ setMessage(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => setIsInputFocused(true)} + onBlur={() => { + if (!message.trim()) setIsInputFocused(false); + }} + placeholder="메시지 입력하기" + style={{ + transition: 'width 0.3s ease-in-out', + width: isInputFocused ? '284px' : '308px', + }} + className={`transition-all duration-300 w-inputWidth h-inputHeight rounded-full placeholder-Gray/4 font-['Pretendard'] pl-[12px] pr-[40px] bg-Gray/5`} + /> + + {/* 이모티콘 버튼 */} +
+ emoticon +
+
+ + {/* 전송 아이콘 */} + +
+ ); +} + +export default ChatBar; diff --git a/src/components/ChatRoomPage/Header.tsx b/src/components/ChatRoomPage/Header.tsx new file mode 100644 index 0000000..2517b25 --- /dev/null +++ b/src/components/ChatRoomPage/Header.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import backIcon from "../../assets/ChatRoom/back.svg"; +import searchIcon from "../../assets/ChatRoom/search.svg"; +import menuIcon from "../../assets/ChatRoom/menu.svg"; + +interface HeaderProps { + opponentUser: { userId: number, userName: string }; +} + +const Header: React.FC = ({ opponentUser }) => { + + const navigate = useNavigate(); + + const handleBackClick = () => { + navigate('/chatlist'); + } + return ( +
+
+ GoBack +

+ {opponentUser.userName} +

+
+ +
+ Search + Menu +
+
+ ); +} + +export default Header; \ No newline at end of file diff --git a/src/components/ChatRoomPage/MessageList.tsx b/src/components/ChatRoomPage/MessageList.tsx new file mode 100644 index 0000000..ea58771 --- /dev/null +++ b/src/components/ChatRoomPage/MessageList.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useRef, useState } from 'react'; +import profileIcon from "../../assets/ChatRoom/profile.svg"; +import { formatTime } from '../../utils/ClockUtils'; +import { useUser } from '../../contexts/UserContext'; +import heartIcon from "../../assets/Reactions/heart.svg"; +import smileIcon from "../../assets/Reactions/smile.svg"; +import ohIcon from "../../assets/Reactions/oh.svg"; +import tearIcon from "../../assets/Reactions/tear.svg"; +import angerIcon from "../../assets/Reactions/angry.svg"; +import thumbIcon from "../../assets/Reactions/thumb.svg"; +import plusIcon from "../../assets/Reactions/plus.svg"; + +interface Message { + senderId: number; + text: string; + timestamp: Date; + emoji?: string; +} + +interface MessageListProps { + messages: Message[]; +} + +// 메시지 그룹화 함수 +const groupMessages = (messages: Message[]) => { + return messages.reduce((arr, message, index) => { + const timeKey = formatTime(message.timestamp); + const prevMessage = messages[index - 1]; + const isSameSender = prevMessage && prevMessage?.senderId === message.senderId; + const isSameMinute = prevMessage && formatTime(prevMessage.timestamp) === timeKey; + + if (!isSameSender || !isSameMinute) { + arr.push({ timeKey, senderId: message.senderId, messages: [message] }); + } else { + arr[arr.length - 1].messages.push(message); + } + + return arr; + }, [] as { timeKey: string; senderId: number; messages: Message[] }[]); +}; + +const MessageList: React.FC = ({ messages }) => { + const { currentUser, opponentUser } = useUser(); + const scrollRef = useRef(null); + const [reactions, setReactions] = useState<{ [key: number]: string | undefined }>({}); + const [showReactions, setShowReactions] = useState(null); + + // 렌더링 시 로컬스토리지에서 리액션 데이터 가져오기 + useEffect(() => { + const storedReactions = localStorage.getItem('messageReactions'); + if (storedReactions) { + setReactions(JSON.parse(storedReactions)); + } + }, []); + + // 로컬스토리지에 리액션 저장 + const saveReactionsToLocalStorage = (updatedReactions: { [key: number]: string | undefined }) => { + localStorage.setItem('messageReactions', JSON.stringify(updatedReactions)); + }; + + + const handleDoubleClick = (index: number, event: React.MouseEvent) => { + event.preventDefault(); // 드래그 방지 + setShowReactions(index === showReactions ? null : index); + }; + + const handleSelectEmoji = (index: number, emoji: string) => { + const currentReaction = reactions[index]; + const updatedReactions = { ...reactions, [index]: currentReaction === emoji ? undefined : emoji }; + setReactions(updatedReactions); + saveReactionsToLocalStorage(updatedReactions); + setShowReactions(null); + }; + + const groupedMessages = groupMessages(messages); + + let lastTimeDisplayed: string | null = null; + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + return ( +
+ {groupedMessages.map((group, groupIndex) => { + const shouldDisplayTime = group.timeKey !== lastTimeDisplayed; + lastTimeDisplayed = group.timeKey; + + return ( +
+ {shouldDisplayTime && ( +
+ {group.timeKey} +
+ )} + +{group.messages.map((message, idx) => { + const messageIndex = groupIndex * 100 + idx; + const isCurrentUser = message.senderId === currentUser?.userId; + + return ( +
handleDoubleClick(messageIndex, e)} + style={{ userSelect: 'none'}} + > + {/* 상대방 메시지 렌더링 */} + {!isCurrentUser && ( +
+ {idx === 0 && ( +
+ Profile +

+ {opponentUser?.userName || '(알 수 없음)'} +

+
+ )} +
+

+ {message.text} +

+ {reactions[messageIndex] && ( +
+
+ Selected Reaction +
+
+ )} +
+ {/* 리액션 바 */} + {showReactions === messageIndex && ( +
+ {[heartIcon, smileIcon, ohIcon, tearIcon, angerIcon, thumbIcon].map((emoji, i) => ( + handleSelectEmoji(messageIndex, emoji)} + > + Reaction + + ))} + AddReaction +
+ )} +
+ )} + + {/* 본인 메시지 렌더링 */} + {isCurrentUser && ( +
+

+ {message.text} +

+ {reactions[messageIndex] && ( +
+
+ Selected Reaction +
+
+ )} +
+ )} +
+ ); +})} +
+ ); + })} +
+
+ ); +}; + +export default MessageList; \ No newline at end of file diff --git a/src/components/FriendListPage/Birthday.tsx b/src/components/FriendListPage/Birthday.tsx new file mode 100644 index 0000000..f1e371e --- /dev/null +++ b/src/components/FriendListPage/Birthday.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import profileIcon from "../../assets/ChatRoom/profile.svg"; +import foldIcon from "../../assets/FriendList/up_arrow.svg"; +import unfoldIcon from "../../assets/FriendList/down_arrow.svg"; + +const Birthday = () => { + const [isFolded, setIsFolded] = useState(false); + + const toggleFold = () => { + setIsFolded(!isFolded); + }; + + return ( +
+
+

생일인 친구

+ fold +
+
+ ); +} + +export default Birthday; \ No newline at end of file diff --git a/src/components/FriendListPage/FriendList.tsx b/src/components/FriendListPage/FriendList.tsx new file mode 100644 index 0000000..03e9ff7 --- /dev/null +++ b/src/components/FriendListPage/FriendList.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import profileIcon from "../../assets/ChatRoom/profile.svg"; +import foldIcon from "../../assets/FriendList/up_arrow.svg"; +import unfoldIcon from "../../assets/FriendList/down_arrow.svg"; +import { UserData } from '../../lib/UserData'; +import { useUser } from '../../contexts/UserContext'; + +const FriendList: React.FC = () => { + const [isFolded, setIsFolded] = useState(false); + const navigate = useNavigate(); + const { currentUser } = useUser(); + + const toggleFold = () => { + setIsFolded(!isFolded); + }; + + const handleProfileClick = (userId: number) => { + if (!currentUser) return; + + // chatKey 생성 + const chatKey = `${Math.min(currentUser.userId, userId)}_${Math.max(currentUser.userId, userId)}`; + + // 클릭한 채팅방으로 이동 + navigate(`/chat/${chatKey}`); + }; + + return ( +
+
+

친구

+ fold +
+ {!isFolded && ( +
+ {UserData.filter(user => user.userId !== currentUser?.userId).map((user) => ( +
handleProfileClick(user.userId)} + > + profile +

+ {user.userName} +

+
+ ))} +
+ )} +
+ ); +} + +export default FriendList; \ No newline at end of file diff --git a/src/components/FriendListPage/Header.tsx b/src/components/FriendListPage/Header.tsx new file mode 100644 index 0000000..868d5cd --- /dev/null +++ b/src/components/FriendListPage/Header.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import searchIcon from "../../assets/ChatRoom/search.svg"; +import addIcon from "../../assets/MyProfile/add_friend.svg"; +import musicIcon from "../../assets/MyProfile/music.svg"; +import settingIcon from "../../assets/ChatRoomList/setting_icon.svg"; + +const Header = () => { + return ( +
+
+

+ 친구 +

+
+ +
+ Search + AddFriend + Music + Setting + +
+
+ ); +} + +export default Header; \ No newline at end of file diff --git a/src/components/FriendListPage/Peong.tsx b/src/components/FriendListPage/Peong.tsx new file mode 100644 index 0000000..a7fa3c5 --- /dev/null +++ b/src/components/FriendListPage/Peong.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import profileIcon from "../../assets/ChatRoom/profile.svg"; +import foldIcon from "../../assets/FriendList/up_arrow.svg"; +import unfoldIcon from "../../assets/FriendList/down_arrow.svg"; +import makeIcon from "../../assets/FriendList/make_peong.svg"; +import peongIcon from "../../assets/FriendList/peong_bg.svg"; + +const Peong = () => { + const [isFolded, setIsFolded] = useState(false); + + const toggleFold = () => { + setIsFolded(!isFolded); + }; + + return ( +
+
+

펑 1

+ fold +
+ {!isFolded && ( +
+
+ makePeong +

만들기

+
+
+ userPeong +

+ + CEOS

+
+
+ )} +
+ ); +} + +export default Peong; \ No newline at end of file diff --git a/src/components/FriendListPage/UpdatedProfile.tsx b/src/components/FriendListPage/UpdatedProfile.tsx new file mode 100644 index 0000000..157b77a --- /dev/null +++ b/src/components/FriendListPage/UpdatedProfile.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import profileIcon from "../../assets/ChatRoom/profile.svg"; +import foldIcon from "../../assets/FriendList/up_arrow.svg"; +import unfoldIcon from "../../assets/FriendList/down_arrow.svg"; +import redDotIcon from "../../assets/FriendList/redDot.svg"; + +const UpdatedProfile = () => { + const [isFolded, setIsFolded] = useState(false); + + const toggleFold = () => { + setIsFolded(!isFolded); + }; + + return ( +
+
+

업데이트한 프로필 3

+ fold +
+ {!isFolded && ( +
+
+ newRedDot +
+ profileImg +

CEOS

+
+
+
+ newRedDot +
+ profileImg +

CEOS

+
+
+ newRedDot +
+ profileImg +

CEOS

+
+
+
+ profileImg +

CEOS

+
+
+ profileImg +

CEOS

+
+
+ profileImg +

CEOS

+
+
+ )} +
+ ); +} + +export default UpdatedProfile; \ No newline at end of file diff --git a/src/components/MyProfilePage/Header.tsx b/src/components/MyProfilePage/Header.tsx new file mode 100644 index 0000000..c8b716a --- /dev/null +++ b/src/components/MyProfilePage/Header.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import backIcon from "../../assets/ChatRoom/back.svg"; +import searchIcon from "../../assets/ChatRoom/search.svg"; +import addIcon from "../../assets/MyProfile/add_friend.svg"; +import musicIcon from "../../assets/MyProfile/music.svg"; +import settingIcon from "../../assets/ChatRoomList/setting_icon.svg"; + +const Header = () => { + + const navigate = useNavigate(); + + const handleBackClick = () => { + navigate(-1); + } + return ( +
+
+ GoBack +

+ 프로필 +

+
+ +
+ Search + AddFriend + Music + Setting + +
+
+ ); +} + +export default Header; \ No newline at end of file diff --git a/src/components/MyProfilePage/ProfileInfo.tsx b/src/components/MyProfilePage/ProfileInfo.tsx new file mode 100644 index 0000000..d5173ff --- /dev/null +++ b/src/components/MyProfilePage/ProfileInfo.tsx @@ -0,0 +1,25 @@ +import arrowIcon from "../../assets/MyProfile/right_arrow.svg"; + +interface ProfileInfoProps { + title: string; + content: string; +} + +const ProfileInfo: React.FC = ({ title, content }) => { + + return ( +
+
+

+ {title} +

+

+ {content} +

+
+ edit +
+ ); +} + +export default ProfileInfo; \ No newline at end of file diff --git a/src/components/MyProfilePage/UserProfile.tsx b/src/components/MyProfilePage/UserProfile.tsx new file mode 100644 index 0000000..92fc03f --- /dev/null +++ b/src/components/MyProfilePage/UserProfile.tsx @@ -0,0 +1,38 @@ +import { useNavigate } from 'react-router-dom'; +import profileIcon from "../../assets/MyProfile/my_profile.svg"; +import multiProfileIcon from "../../assets/MyProfile/multiProfile.svg"; +import { useUser } from '../../contexts/UserContext'; + +const UserProfile = () => { + const navigate = useNavigate(); + const { currentUser, toggleUser } = useUser(); + + const handleProfileClick = () => { + navigate('/my'); + }; + + return ( +
+
+ profileImg +

+ {currentUser?.userName} +

+
+ setMultiProfile { + e.stopPropagation(); // 부모 클릭 이벤트 방지 + toggleUser(); // 전역 상태에서 유저 토글 + }} + className='cursor-pointer' + /> +
+ ); +} + +export default UserProfile; \ No newline at end of file diff --git a/src/components/common/HomeIndicatior.tsx b/src/components/common/HomeIndicatior.tsx new file mode 100644 index 0000000..73a1bdf --- /dev/null +++ b/src/components/common/HomeIndicatior.tsx @@ -0,0 +1,13 @@ +import indicator from "../../assets/Common/HomeIndicator.svg"; + +const HomeIndicator = () => { + return ( +
+
+ indicator +
+
+ ); +} + +export default HomeIndicator; diff --git a/src/components/common/Line.tsx b/src/components/common/Line.tsx new file mode 100644 index 0000000..8fdcaf7 --- /dev/null +++ b/src/components/common/Line.tsx @@ -0,0 +1,8 @@ +const Line = () => { + return ( +
+
+ ); +} + +export default Line; diff --git a/src/components/common/NavBar.tsx b/src/components/common/NavBar.tsx new file mode 100644 index 0000000..ea2079f --- /dev/null +++ b/src/components/common/NavBar.tsx @@ -0,0 +1,66 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { ReactComponent as FriendIcon } from "../../assets/ChatRoomList/friend_icon.svg"; +import { ReactComponent as ChatIcon } from "../../assets/ChatRoomList/chatting_icon.svg"; +import { ReactComponent as OpenedChatIcon } from "../../assets/ChatRoomList/openedChat_icon.svg"; +import { ReactComponent as ShoppingIcon } from "../../assets/ChatRoomList/shopping_icon.svg"; +import { ReactComponent as MoreIcon } from "../../assets/ChatRoomList/more_icon.svg"; +import { useUnread } from '../../contexts/UnreadContext'; + +const NavBar: React.FC = () => { + const basicBtnStyle = "text-[9px] font-['Pretendard'] flex flex-col items-center cursor-pointer"; + const navigate = useNavigate(); + const location = useLocation(); + const [activePath, setActivePath] = useState(location.pathname); + const { totalUnread } = useUnread(); + + useEffect(() => { + setActivePath(location.pathname); + }, [location]); + + const onBtnClick = (path: string) => { + setActivePath(path); + navigate(path); + } + + const getBtnStyle = (path: string) => { + return activePath === path + ? `text-Purple/1 ${basicBtnStyle}` + : `text-Gray/2 ${basicBtnStyle}`; + } + + const getIconStyle = (path: string) => { + return activePath === path ? "#AB78FF" : "#666666"; + } + + return ( +
+
onBtnClick("/friends")} className={getBtnStyle("/friends")} style={{color: getIconStyle("/friends")}}> + 친구 +
+
onBtnClick("/chatlist")} className={`${getBtnStyle("/chatlist")} relative`} style={{color: getIconStyle("/chatlist")}}> + + {totalUnread > 0 && ( +
+ {totalUnread} +
+ )} + 채팅 +
+
onBtnClick("/openedChat")} className={getBtnStyle("/openedChat")} style={{color: getIconStyle("/openedChat")}}> + 오픈채팅 +
+
onBtnClick("/shopping")} className={getBtnStyle("/shopping")} style={{color: getIconStyle("/shopping")}}> + 쇼핑 +
+
onBtnClick("/more")} className={getBtnStyle("/more")} style={{color: getIconStyle("/more")}}> + 더보기 +
+
+ ); +} + +export default NavBar; diff --git a/src/components/common/TopBar.tsx b/src/components/common/TopBar.tsx new file mode 100644 index 0000000..7bea561 --- /dev/null +++ b/src/components/common/TopBar.tsx @@ -0,0 +1,34 @@ +import React, { useState, useEffect } from 'react'; +import { currentTime } from '../../utils/ClockUtils'; +import BatteryIcon from '../../assets/TopBar/Battery.svg'; +import WifiIcon from '../../assets/TopBar/Wifi.svg'; +import NetworkIcon from '../../assets/TopBar/CellularConnection.svg'; + +const TopBar = () => { + const [time, setTime] = useState(currentTime()); + + useEffect(() => { + const interval = setInterval(() => { + setTime(currentTime()); + }, 1000); + + return () => clearInterval(interval); + }, []); + + return ( +
+ {/* 현재 시간 표시 */} +
+ {time} +
+ {/* 상단바 아이콘 */} +
+ Network + Wi-Fi + Battery +
+
+ ); +} + +export default TopBar; diff --git a/src/contexts/UnreadContext.tsx b/src/contexts/UnreadContext.tsx new file mode 100644 index 0000000..9af8edd --- /dev/null +++ b/src/contexts/UnreadContext.tsx @@ -0,0 +1,49 @@ +import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react'; +import { useUser } from './UserContext'; +import { UserData } from '../lib/UserData'; + +interface UnreadContextType { + totalUnread: number; + calculateUnread: () => void; +} + +const UnreadContext = createContext(undefined); + +export const UnreadProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const { currentUser } = useUser(); + const [totalUnread, setTotalUnread] = useState(0); + + const calculateUnread = () => { + if (!currentUser) return; + let cnt = 0; + UserData.forEach(user => { + if (user.userId !== currentUser.userId) { + const chatKey = `messages_${Math.min(currentUser.userId, user.userId)}_${Math.max(currentUser.userId, user.userId)}`; + const savedMessages = localStorage.getItem(chatKey); + if (savedMessages) { + const parsedMessages = JSON.parse(savedMessages); + cnt += parsedMessages.filter((msg: any) => !msg.read && msg.senderId !== currentUser.userId).length; + } + } + }); + setTotalUnread(cnt); + }; + + useEffect(() => { + calculateUnread(); + }, [currentUser]); + + return ( + + {children} + + ); +}; + +export const useUnread = (): UnreadContextType => { + const context = useContext(UnreadContext); + if (!context) { + throw new Error("error"); + } + return context; +}; \ No newline at end of file diff --git a/src/contexts/UserContext.tsx b/src/contexts/UserContext.tsx new file mode 100644 index 0000000..71fdbcd --- /dev/null +++ b/src/contexts/UserContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react'; +import { UserData } from '../lib/UserData'; + +interface User { + userId: number; + userName: string; + phoneNumber: string; + email: string; + sns: string; +} + +interface UserContextType { + currentUser: User | null; + opponentUser: User | null; + toggleUser: () => void; +} + +const UserContext = createContext(undefined); + +interface UserProviderProps { + children: ReactNode; +} + +export const UserProvider = ({ children }: UserProviderProps) => { + // 로컬스토리지에서 currentUser 가져오기 + const storedUser = localStorage.getItem('currentUser'); + const initialUser = storedUser ? JSON.parse(storedUser) : UserData.find(user => user.userId === 0) || null; + + const [currentUser, setCurrentUser] = useState(initialUser); + + // currentUser가 변경될 때마다 로컬스토리지에 저장 + useEffect(() => { + if (currentUser) { + localStorage.setItem('currentUser', JSON.stringify(currentUser)); + } + }, [currentUser]); + + // currentUser의 userId에 따라 opponentUser 설정 + const opponentUser: User | null = currentUser?.userId === 0 + ? UserData.find(user => user.userId === 1) || null + : UserData.find(user => user.userId === 0) || null; + + const toggleUser = () => { + const newUser = currentUser?.userId === 0 + ? UserData.find(user => user.userId === 1) + : UserData.find(user => user.userId === 0); + if (newUser) setCurrentUser(newUser); + }; + + return ( + + {children} + + ); +}; + +export const useUser = (): UserContextType => { + const context = useContext(UserContext); + if (!context) { + throw new Error("error"); + } + return context; +}; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 0000000..4ec4690 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: any; + export default content; +} \ No newline at end of file diff --git a/src/declaration.d.ts b/src/declaration.d.ts new file mode 100644 index 0000000..d7d1bdc --- /dev/null +++ b/src/declaration.d.ts @@ -0,0 +1,6 @@ +declare module "*.svg" { + import * as React from 'react'; + export const ReactComponent: React.FunctionComponent>; + const src: string; + export default src; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index ec2585e..b155dce 100644 --- a/src/index.css +++ b/src/index.css @@ -1,13 +1,25 @@ -body { +@tailwind base; +@tailwind components; +@tailwind utilities; + +@font-face { + font-family: "Pretendard-Regular"; + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff") + format("woff"); + font-weight: 400; + font-style: normal; +} + +/* body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + 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', + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; -} +} */ diff --git a/src/index.tsx b/src/index.tsx index d10be77..5e97bd5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,12 +2,15 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; +import { BrowserRouter } from 'react-router-dom'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( - + + + ); diff --git a/src/lib/MessageData.tsx b/src/lib/MessageData.tsx new file mode 100644 index 0000000..4cf8d9e --- /dev/null +++ b/src/lib/MessageData.tsx @@ -0,0 +1,26 @@ +export const MessageData = [ + { + senderId: 0, + text: "안녕하세요!", + timestamp: new Date('2024-09-25T10:30:00'), + read: true, + }, + { + senderId: 0, + text: "1주차 세션 팀구성이 궁금합니다!", + timestamp: new Date('2024-09-25T10:30:00'), + read: true, + }, + { + senderId: 1, + text: "안녕하세요!", + timestamp: new Date('2024-09-25T10:35:00'), + read: true, + }, + { + senderId: 1, + text: "세오스 공개용 노션에 비상관제시어 관련하여 공지 업로드 했습니다. 참고해주세요!", + timestamp: new Date('2024-09-25T10:35:00'), + read: true, + }, +]; diff --git a/src/lib/UserData.tsx b/src/lib/UserData.tsx new file mode 100644 index 0000000..9ee2f74 --- /dev/null +++ b/src/lib/UserData.tsx @@ -0,0 +1,51 @@ +export const UserData = [ + { + userId: 0, + userName: '김태양', + phoneNumber: '2052-8337', + email: 'tangerin2601@naver.com', + sns: '@taeyaaaaaaang', + }, + { + userId: 1, + userName: '이가빈', + phoneNumber: '1886-1029', + email: 'billy@gmail.com', + sns: '@billyboo', + }, + { + userId: 2, + userName: '김민지', + phoneNumber: '2004-0507', + email: 'minji@gmail.com', + sns: '@newjeans_official', + }, + { + userId: 3, + userName: '팜하니', + phoneNumber: '2004-1006', + email: 'hanni@gmail.com', + sns: '@newjeans_official', + }, + { + userId: 4, + userName: '모지혜', + phoneNumber: '2005-0411', + email: 'danielle@gmail.com', + sns: '@newjeans_official', + }, + { + userId: 5, + userName: '강해린', + phoneNumber: '2006-0515', + email: 'haerin@gmail.com', + sns: '@newjeans_official', + }, + { + userId: 6, + userName: '이혜인', + phoneNumber: '2008-0421', + email: 'hyein@gmail.com', + sns: '@newjeans_official', + }, +]; \ No newline at end of file diff --git a/src/pages/ChatRoomListPage.tsx b/src/pages/ChatRoomListPage.tsx new file mode 100644 index 0000000..4894a98 --- /dev/null +++ b/src/pages/ChatRoomListPage.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import HomeIndicator from '../components/common/HomeIndicatior'; +import TopBar from '../components/common/TopBar'; +import NavBar from '../components/common/NavBar'; +import Header from '../components/ChatRoomListPage/Header'; +import Line from '../components/common/Line'; +import ChatRoomComponent from '../components/ChatRoomListPage/ChatRoomComponent'; +import { UserData } from '../lib/UserData'; +import { useUser } from '../contexts/UserContext'; +import { useUnread } from '../contexts/UnreadContext'; + +interface Message { + senderId: number; + text: string; + timestamp: Date; + read: boolean; +} + +const ChatRoomListPage: React.FC = () => { + const navigate = useNavigate(); + const { currentUser } = useUser(); + const [lastMessages, setLastMessages] = useState<{ [userId: number]: Message | null }>({}); + const [unread, setUnread] = useState<{ [userId: number]: number }>({}); + const { calculateUnread } = useUnread(); + + useEffect(() => { + //localStorage.clear(); + const newUnread: { [userId: number]: number } = {}; + const lastMessagesByUser: { [userId: number]: Message | null } = {}; + + UserData.forEach(user => { + // 각 유저별 메시지 불러오기 + if (currentUser && user.userId !== currentUser.userId) { + // currentUser와 user 간의 공통 대화 키 생성 + const chatKey = `messages_${Math.min(currentUser.userId, user.userId)}_${Math.max(currentUser.userId, user.userId)}`; + const savedMessages = localStorage.getItem(chatKey); + if (savedMessages) { + const parsedMessages: Message[] = JSON.parse(savedMessages).map((msg: any) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })); + + lastMessagesByUser[user.userId] = parsedMessages.length > 0 ? parsedMessages[parsedMessages.length - 1] : null; + newUnread[user.userId] = parsedMessages.filter(msg => !msg.read && msg.senderId !== currentUser.userId).length; + } else { + lastMessagesByUser[user.userId] = null; + newUnread[user.userId] = 0; + } + } + }); + + setLastMessages(lastMessagesByUser); + setUnread(newUnread); + }, [currentUser]); + + const handleChatRoomClick = (userId: number) => { + if (!currentUser) return; + + // chatKey 생성 + const chatKey = `messages_${Math.min(currentUser.userId, userId)}_${Math.max(currentUser.userId, userId)}`; + + // 해당 채팅방의 안 읽은 메시지 개수 0으로 초기화 + setUnread(prevUnread => ({ + ...prevUnread, + [userId]: 0 + })); + + // 로컬스토리지에서 해당 채팅방의 모든 메시지를 읽음으로 표시 + const savedMessages = localStorage.getItem(chatKey); + if (savedMessages) { + const messages: Message[] = (JSON.parse(savedMessages) as Message[]).map((msg: Message) => + msg.senderId !== currentUser.userId ? { ...msg, read: true } : msg + ); + localStorage.setItem(chatKey, JSON.stringify(messages)); + calculateUnread(); + } + + // 클릭한 채팅방으로 이동 + navigate(`/chat/${Math.min(currentUser.userId, userId)}_${Math.max(currentUser.userId, userId)}`); + }; + + return ( +
+ +
+ + {UserData.filter(user => currentUser && user.userId !== currentUser.userId).map(user => ( + lastMessages[user.userId] && ( + handleChatRoomClick(user.userId)} + unread={unread[user.userId] || 0} + /> + ) + ))} +
+ + +
+
+ ); +} + +export default ChatRoomListPage; diff --git a/src/pages/ChatRoomPage.tsx b/src/pages/ChatRoomPage.tsx new file mode 100644 index 0000000..33ff25a --- /dev/null +++ b/src/pages/ChatRoomPage.tsx @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import ChatBar from '../components/ChatRoomPage/ChatBar'; +import Header from '../components/ChatRoomPage/Header'; +import HomeIndicator from '../components/common/HomeIndicatior'; +import TopBar from '../components/common/TopBar'; +import MessageList from '../components/ChatRoomPage/MessageList'; +import { MessageData } from '../lib/MessageData'; +import { UserData } from '../lib/UserData'; +import { useUser } from '../contexts/UserContext'; + +interface Message { + senderId: number; + text: string; + timestamp: Date; +} + +const ChatRoomPage: React.FC = () => { + const { chatKey } = useParams<{ chatKey: string }>(); + const { currentUser } = useUser(); + const [messages, setMessages] = useState([]); + + // chatKey에서 userId 추출 + const [user1Id, user2Id] = chatKey?.split('_').map(Number) || []; + const opponentUser = UserData.find(user => user.userId === (currentUser?.userId === user1Id ? user2Id : user1Id)); + + // 초기 메시지 및 로컬스토리지 메시지 불러오기 + useEffect(() => { + if (!chatKey) return; + + const isDefaultChatKey = chatKey === '0_1' || chatKey === '1_0'; + + const savedMessages = localStorage.getItem(`messages_${chatKey}`); + if (savedMessages) { + const parsedMessages: Message[] = JSON.parse(savedMessages).map((msg: any) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })); + setMessages(parsedMessages); + } else if (isDefaultChatKey) { + // 기본 메시지 설정 + const initialMessages: Message[] = MessageData.filter( + msg => msg.senderId === opponentUser?.userId || msg.senderId === currentUser?.userId + ).map(msg => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })); + setMessages(initialMessages); + + // 초기 메시지 로컬스토리지에 저장 + localStorage.setItem(`messages_${chatKey}`, JSON.stringify(initialMessages)); + + } + }, [chatKey, opponentUser, currentUser]); + + // 메시지 전송 핸들러 + const handleSendMessage = (message: string) => { + if (!chatKey || !currentUser) return; + + const newMessage: Message = { + senderId: currentUser.userId, + text: message, + timestamp: new Date() + }; + + setMessages((prevMessages) => { + const updatedMessages = [...prevMessages, newMessage]; + // 로컬 스토리지에 저장 + localStorage.setItem(`messages_${chatKey}`, JSON.stringify(updatedMessages)); + + return updatedMessages; + }); + }; + + if (!opponentUser) return
상대방 정보를 찾을 수 없습니다.
; + + return ( +
+ +
+ +
+ + +
+
+ ); +} + +export default ChatRoomPage; diff --git a/src/pages/FriendListPage.tsx b/src/pages/FriendListPage.tsx new file mode 100644 index 0000000..2a57344 --- /dev/null +++ b/src/pages/FriendListPage.tsx @@ -0,0 +1,37 @@ +import React, { useState, useEffect } from 'react'; +import HomeIndicator from '../components/common/HomeIndicatior'; +import TopBar from '../components/common/TopBar'; +import NavBar from '../components/common/NavBar'; +import Header from '../components/FriendListPage/Header'; +import UserProfile from '../components/MyProfilePage/UserProfile'; +import UpdatedProfile from '../components/FriendListPage/UpdatedProfile'; +import Peong from '../components/FriendListPage/Peong'; +import Birthday from '../components/FriendListPage/Birthday'; +import FriendList from '../components/FriendListPage/FriendList'; +import Line from '../components/common/Line'; + +const FriendListPage = () => { + return ( +
+ +
+
+ + + + + + + + + +
+
+ + +
+
+ ); +} + +export default FriendListPage; diff --git a/src/pages/MyProfilePage.tsx b/src/pages/MyProfilePage.tsx new file mode 100644 index 0000000..db7726e --- /dev/null +++ b/src/pages/MyProfilePage.tsx @@ -0,0 +1,35 @@ +import React, { useState, useEffect } from 'react'; +import HomeIndicator from '../components/common/HomeIndicatior'; +import TopBar from '../components/common/TopBar'; +import NavBar from '../components/common/NavBar'; +import Header from '../components/MyProfilePage/Header'; +import UserProfile from '../components/MyProfilePage/UserProfile'; +import Line from '../components/common/Line'; +import ProfileInfo from '../components/MyProfilePage/ProfileInfo'; +import { useUser } from '../contexts/UserContext'; + +const MyProfilePage = () => { + const { currentUser } = useUser(); + + return ( +
+ +
+ + + {currentUser && ( + <> + + + + + )} +
+ + +
+
+ ); +} + +export default MyProfilePage; diff --git a/src/pages/NotYetPage.tsx b/src/pages/NotYetPage.tsx new file mode 100644 index 0000000..7714aff --- /dev/null +++ b/src/pages/NotYetPage.tsx @@ -0,0 +1,27 @@ +import HomeIndicator from '../components/common/HomeIndicatior'; +import TopBar from '../components/common/TopBar'; +import NavBar from '../components/common/NavBar'; +import Icon from "../assets/Common/shield_warning.svg"; + +const NotYetPage = () => { + return ( +
+ +
+ +

+ 서비스 준비중입니다.

+

+ 보다 나은 서비스 제공을 위하여 페이지 준비중에 있습니다.

+

+ 빠른 시일 내에 준비하여 찾아뵙겠습니다.

+
+
+ + +
+
+ ); +} + +export default NotYetPage; diff --git a/src/utils/ClockUtils.tsx b/src/utils/ClockUtils.tsx new file mode 100644 index 0000000..667a939 --- /dev/null +++ b/src/utils/ClockUtils.tsx @@ -0,0 +1,72 @@ +// 현재 시간 포맷 (상단바) +export const currentTime = () => { + return new Date().toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +}; + +// 타임스탬프 포맷 +export const formatTime = (date: any): string => { + // Date 객체가 아닌 경우 + if (!(date instanceof Date) || isNaN(date.getTime())) { + return 'Invalid date'; + } + + const now = new Date(); + const diffInTime = now.getTime() - date.getTime(); + const diffInDays = Math.floor(diffInTime / (1000 * 60 * 60 * 24)); + + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? '오후' : '오전'; + const formattedHours = hours % 12 === 0 ? 12 : hours % 12; + const timeString = `${ampm} ${formattedHours}:${minutes < 10 ? '0' : ''}${minutes}`; + + if (diffInDays === 0) { + return timeString; + } else if (diffInDays === 1) { + return `(어제) ${timeString}`; + } else if (diffInDays === 2) { + return `(그저께) ${timeString}`; + } else if (diffInDays <= 3) { + return `(${diffInDays}일 전) ${timeString}`; + } else { + const month = date.getMonth() + 1; + const day = date.getDate(); + return `(${month}/${day}) ${timeString}`; + } +}; + +// 채팅 리스트용 타임스탬프 +export const formatTimeForChatList = (date: any): string => { + // Date 객체가 아닌 경우 + if (!(date instanceof Date) || isNaN(date.getTime())) { + return 'Invalid date'; + } + + const now = new Date(); + const diffInTime = now.getTime() - date.getTime(); + const diffInDays = Math.floor(diffInTime / (1000 * 60 * 60 * 24)); + + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? '오후' : '오전'; + const formattedHours = hours % 12 === 0 ? 12 : hours % 12; + const timeString = `${ampm} ${formattedHours}:${minutes < 10 ? '0' : ''}${minutes}`; + + if (diffInDays === 0) { + return timeString; + } else if (diffInDays === 1) { + return `어제`; + } else if (diffInDays === 2) { + return `그저께`; + } else if (diffInDays <= 3) { + return `${diffInDays}일 전)`; + } else { + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${month}/${day}`; + } +}; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..f3a28fe --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,42 @@ +/** @type {import('tailwindcss').Config} */ + +module.exports = { + content: [ "./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], + theme: { + colors: { + 'Purple/1': '#AB78FF', + 'Purple/2': '#B98FFF', + 'Purple/3': '#D3B8FF', + 'White': '#FFFFFF', + 'Gray/2': '#666666', + 'Gray/3': '#999999', + 'Gray/4' : "#CCCCCC", + 'Gray/5': '#EDEDED', + 'Black': '#000000', + }, + extend: { + width: { + 'width': '375px', + 'inputWidth':'308px', + }, + height: { + 'height': '812px', + 'topBarHeight': '52px', + 'headerHeight': '48px', + 'messageAreaHeight':'631px', + 'chatBarHeight': '68px', + 'inputHeight': '40px', + 'indicatorHeight': '13px', + 'navBarHeight': '70px', + 'ChatRoomComponentHeight': '76px', + 'profileTitleHeight': '86px', + }, + } + }, + plugins: [ + require("tailwind-scrollbar-hide"), + require('@tailwindcss/line-clamp'), + ], +} + + diff --git a/tsconfig.json b/tsconfig.json index a273b0c..1152a63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "jsx": "react-jsx" }, "include": [ - "src" + "src", + "src/custom.d.ts", ] }