diff --git a/src/backend/auth-server/src/auth/auth.service.ts b/src/backend/auth-server/src/auth/auth.service.ts index 2b97bb98..3ac4c448 100644 --- a/src/backend/auth-server/src/auth/auth.service.ts +++ b/src/backend/auth-server/src/auth/auth.service.ts @@ -196,7 +196,7 @@ export class AuthService { const isPasswordMatch = await bcrypt.compare(password, user.password); if (!isPasswordMatch) { - throw new BadRequestException(MESSAGES.INVALID_LOGIN_INFO); + throw new UnauthorizedException(MESSAGES.INVALID_LOGIN_INFO); } const { password: _, ...withoutPassword } = user; diff --git a/src/backend/user-server/src/user/user.controller.ts b/src/backend/user-server/src/user/user.controller.ts index cba7705e..80b17c4d 100644 --- a/src/backend/user-server/src/user/user.controller.ts +++ b/src/backend/user-server/src/user/user.controller.ts @@ -102,8 +102,9 @@ export class UserController { async findAll( @Query("page", new DefaultValuePipe(0), ParseIntPipe) page: number = 0, @Query("size", new DefaultValuePipe(10), ParseIntPipe) size: number = 10, + @Query("nickname") nickname?: string, ) { - return this.userService.findAll(page, size); + return this.userService.findAll(page, size, nickname); } @Get(":id") diff --git a/src/backend/user-server/src/user/user.service.ts b/src/backend/user-server/src/user/user.service.ts index 5fbc93ca..135303c4 100644 --- a/src/backend/user-server/src/user/user.service.ts +++ b/src/backend/user-server/src/user/user.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Like, Repository } from "typeorm"; import { User } from "./entity/user.entity"; import { CreateUserDto } from "./dto/create-user.dto"; import * as bcrypt from "bcryptjs"; @@ -17,6 +17,7 @@ import { REDIS_KEY } from "./constants/redis-key.constant"; import { DeviceType } from "./enum/device-type.enum"; import { MESSAGES } from "./constants/constants"; import { ENV_KEY } from "./constants/env-key.constants"; + @Injectable() export class UserService { private readonly redis: Redis; @@ -68,15 +69,18 @@ export class UserService { return this.userRepository.findOne({ where: { email } }); } - // NOTE: pagination 필요할 경우 추가 - async findAll(page: number = 0, size: number = 10) { + async findAll(page: number = 0, size: number = 10, nickname?: string) { + const whereCondition = nickname ? { nickname: Like(`%${nickname}%`) } : {}; + const [users, total] = await this.userRepository.findAndCount({ + where: whereCondition, skip: page * size, take: size, }); + return { users, - total, + totalLength: total, }; } diff --git a/src/frontend/index.html b/src/frontend/index.html index 57100265..5b9162f9 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -2,9 +2,9 @@ - + - Kicktube + KickTube
diff --git a/src/frontend/package.json b/src/frontend/package.json index 30aaad97..b608f992 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -18,6 +18,7 @@ "@types/sockjs-client": "^1.5.4", "@types/stompjs": "^2.3.9", "axios": "^1.7.9", + "lucide-react": "^0.475.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.3", diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index b3e047a4..0bea3881 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: axios: specifier: ^1.7.9 version: 1.7.9 + lucide-react: + specifier: ^0.475.0 + version: 0.475.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -1991,6 +1994,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.475.0: + resolution: {integrity: sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -4711,6 +4719,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.475.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.27.0: diff --git a/src/frontend/public/favicon.svg b/src/frontend/public/favicon.svg new file mode 100644 index 00000000..66b75fee --- /dev/null +++ b/src/frontend/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/frontend/src/api/endpoints/friend/friend.api.ts b/src/frontend/src/api/endpoints/friend/friend.api.ts new file mode 100644 index 00000000..1d23074d --- /dev/null +++ b/src/frontend/src/api/endpoints/friend/friend.api.ts @@ -0,0 +1,49 @@ +import instance from '@/api/axios.instance'; +import { FriendDto, NotificationDto } from './friend.interface'; + +export const friendApi = { + // 친구 목록 제공 + getFriends: async () => { + const { data } = await instance.get<{ friends: FriendDto[] }>('/friends/list'); + return data.friends; + }, + + // 친구 요청 + requestFriend: async (me: number, receiverId: number) => { + const { data } = await instance.post(`/friends/request`, { + senderId: me, + receiverId, + }); + return data; + }, + + // 친구 요청 수락 + acceptFriend: async (me: number, senderId: number) => { + const { data } = await instance.post(`/friends/accept`, { + senderId, + receiverId: me, + }); + return data; + }, + + // 친구 요청 거절 + rejectFriend: async (me: number, senderId: number) => { + const { data } = await instance.post(`/friends/reject`, { + senderId, + receiverId: me, + }); + return data; + }, + + // 알림 정보 + getNotifications: async () => { + const { data } = await instance.get<{ requests: NotificationDto[] }>('/friends/requests'); + return data.requests; + }, + + // 안 읽은 알림 개수 제공 + getUnreadNotificationsCount: async () => { + const { data } = await instance.get('/friends/unread'); + return data; + }, +}; diff --git a/src/frontend/src/api/endpoints/friend/friend.interface.ts b/src/frontend/src/api/endpoints/friend/friend.interface.ts new file mode 100644 index 00000000..efb891c8 --- /dev/null +++ b/src/frontend/src/api/endpoints/friend/friend.interface.ts @@ -0,0 +1,23 @@ +export interface FriendDto { + friend_id: number; + nickname: string; + profile_image_url?: string; + role: number; + status: string; +} + +export interface NotificationDto { + isRead: boolean; + receiverId: number; + receiverNickname: string; + roomCode: string | null; + roomId: string | null; + senderId: number; + senderNickname: string; + status: NotificationStatus; + timestamp: number; + type: NotificationType; +} + +export type NotificationStatus = 'PENDING' | 'ACCEPTED' | 'REJECTED'; +export type NotificationType = 'friend_request' | 'room_request'; diff --git a/src/frontend/src/api/endpoints/user/user.api.ts b/src/frontend/src/api/endpoints/user/user.api.ts index 72707d0d..7664b72b 100644 --- a/src/frontend/src/api/endpoints/user/user.api.ts +++ b/src/frontend/src/api/endpoints/user/user.api.ts @@ -15,8 +15,11 @@ export const userApi = { }, // 전체 유저 조회 - getUsers: async (page: number = 0, size: number = 10) => { - const { data } = await instance.get(`users`, { params: { page, size } }); + getUsers: async (page: number = 0, size: number = 10, nickname?: string) => { + const { data } = await instance.get<{ users: UserResponseDto[]; totalLength: number }>( + `users`, + { params: { page, size, nickname } }, + ); return data; }, diff --git a/src/frontend/src/assets/img/AddUser.svg b/src/frontend/src/assets/img/AddUser.svg index 4887f7d0..a5de72ba 100644 --- a/src/frontend/src/assets/img/AddUser.svg +++ b/src/frontend/src/assets/img/AddUser.svg @@ -1,10 +1,3 @@ - - - - - - - - - + + diff --git a/src/frontend/src/assets/img/CircleInformation.svg b/src/frontend/src/assets/img/CircleInformation.svg new file mode 100644 index 00000000..1621eb98 --- /dev/null +++ b/src/frontend/src/assets/img/CircleInformation.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/frontend/src/assets/img/PeopleSearch.svg b/src/frontend/src/assets/img/PeopleSearch.svg new file mode 100644 index 00000000..8661b4b5 --- /dev/null +++ b/src/frontend/src/assets/img/PeopleSearch.svg @@ -0,0 +1 @@ + diff --git a/src/frontend/src/assets/img/ViewVideo.svg b/src/frontend/src/assets/img/ViewVideo.svg new file mode 100644 index 00000000..23cb0328 --- /dev/null +++ b/src/frontend/src/assets/img/ViewVideo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/components/Friend/FriendCard/index.css.ts b/src/frontend/src/components/Friend/FriendCard/index.css.ts new file mode 100644 index 00000000..e7829612 --- /dev/null +++ b/src/frontend/src/components/Friend/FriendCard/index.css.ts @@ -0,0 +1,49 @@ +import { styled } from 'styled-components'; + +export const FriendCardContainer = styled.div` + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + max-width: 300px; +`; + +export const FriendCardImage = styled.div<{ $isOnline: boolean }>` + position: relative; + width: 3rem; + height: 3rem; + border-radius: 10px; + + img { + width: 100%; + height: 100%; + border-radius: 10px; + } + + &::before { + content: ''; + position: absolute; + right: -3px; + bottom: -3px; + width: 16px; + height: 16px; + background-color: #fff; + border-radius: 10px; + z-index: 1; + } + + &::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + width: 10px; + height: 10px; + background-color: ${props => + props.$isOnline ? 'var(--palette-status-positive)' : 'var(--palette-interaction-inactive)'}; + border-radius: 10px; + z-index: 2; + } +`; diff --git a/src/frontend/src/components/Friend/FriendCard/index.tsx b/src/frontend/src/components/Friend/FriendCard/index.tsx new file mode 100644 index 00000000..8279bceb --- /dev/null +++ b/src/frontend/src/components/Friend/FriendCard/index.tsx @@ -0,0 +1,20 @@ +import { FriendDto } from '@/api/endpoints/friend/friend.interface'; +import { FriendCardContainer, FriendCardImage } from './index.css'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; + +export const FriendCard = ({ friend }: { friend: FriendDto }) => { + return ( + + + { + e.currentTarget.src = DefaultProfile; + }} + alt="profile" + /> + +
{friend.nickname}
+ + ); +}; diff --git a/src/frontend/src/components/LeftNavBar/index.css.ts b/src/frontend/src/components/LeftNavBar/index.css.ts index 24edbfd7..b8bed02d 100644 --- a/src/frontend/src/components/LeftNavBar/index.css.ts +++ b/src/frontend/src/components/LeftNavBar/index.css.ts @@ -8,6 +8,7 @@ export const ButtonContainer = styled.div` align-items: center; flex-direction: column; gap: 20px; + border-right: 1px solid var(--palette-line-solid-neutral); `; export const ButtonWrapper = styled.div` diff --git a/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.css.ts b/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.css.ts index 67bb51ce..3153cd97 100644 --- a/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.css.ts +++ b/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.css.ts @@ -1,6 +1,7 @@ import { styled } from 'styled-components'; export const Card = styled.div` + width: 100%; background: var(--palette-static-white); padding: 15px; margin-bottom: 10px; diff --git a/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.tsx b/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.tsx index 1c009117..6b5df268 100644 --- a/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.tsx +++ b/src/frontend/src/components/Modal/NotificationModal/NotificationCard/index.tsx @@ -7,41 +7,88 @@ import { RejectButton, Strong, } from './index.css'; -import { NotificationDto } from '@/types/dto/Notification.dto'; +import { NotificationDto } from '@/api/endpoints/friend/friend.interface'; import { formatDateToKorean } from '@/utils/dateUtils'; interface INotificationCard { notification: NotificationDto; - onAccept: (id: number) => void; - onReject: (id: number) => void; + onAccept: (notification: NotificationDto) => void; + onReject: (notification: NotificationDto) => void; } export const NotificationCard = ({ notification, onAccept, onReject }: INotificationCard) => { - return ( - - - {notification.type === 'room_invite' ? ( - <> - {notification.sender.nickname}님께서{' '} - {notification.roomTitle} 방에 초대하셨습니다. - - ) : ( - <> - {notification.sender.nickname}님께서 친구 요청을 보냈습니다. - - )} - - {formatDateToKorean(notification.timestamp)} - - {notification.status === 'pending' && ( + const { type, senderNickname, roomId, timestamp, status } = notification; + + const message = (() => { + if (type === 'room_request') { + switch (status) { + case 'PENDING': + return ( + <> + {senderNickname}님께서 {roomId}번 방에 + 초대하셨습니다. + + ); + case 'ACCEPTED': + return ( + <> + {senderNickname}님의 초대를 수락하셨습니다. + + ); + case 'REJECTED': + return ( + <> + {senderNickname}님의 초대를 거절하셨습니다. + + ); + } + } else { + switch (status) { + case 'PENDING': + return ( + <> + {senderNickname}님께서 친구 요청을 보냈습니다. + + ); + case 'ACCEPTED': + return ( + <> + {senderNickname}님의 친구 요청을 수락하셨습니다. + + ); + case 'REJECTED': + return ( + <> + {senderNickname}님의 친구 요청을 거절하셨습니다. + + ); + } + } + })(); + + const renderButtons = () => { + switch (status) { + case 'PENDING': + return ( <> - onAccept(notification.id)}>수락 - onReject(notification.id)}>거절 + onAccept(notification)}>수락 + onReject(notification)}>거절 - )} - {notification.status === 'accepted' && 수락} - {notification.status === 'rejected' && 거절} - + ); + case 'ACCEPTED': + return 수락됨; + case 'REJECTED': + return 거절됨; + default: + return null; + } + }; + + return ( + + {message} + {formatDateToKorean(timestamp)} + {renderButtons()} ); }; diff --git a/src/frontend/src/components/Modal/NotificationModal/index.css.ts b/src/frontend/src/components/Modal/NotificationModal/index.css.ts index 3a91a1e0..719ba903 100644 --- a/src/frontend/src/components/Modal/NotificationModal/index.css.ts +++ b/src/frontend/src/components/Modal/NotificationModal/index.css.ts @@ -2,6 +2,7 @@ import { styled } from 'styled-components'; export const NotiContainer = styled.div` position: relative; + width: 100%; max-height: 260px; padding: 20px 20px 0px 20px; overflow-y: auto; diff --git a/src/frontend/src/components/Modal/NotificationModal/index.tsx b/src/frontend/src/components/Modal/NotificationModal/index.tsx index bcb6c2b8..ff6683de 100644 --- a/src/frontend/src/components/Modal/NotificationModal/index.tsx +++ b/src/frontend/src/components/Modal/NotificationModal/index.tsx @@ -1,46 +1,56 @@ -import { useState } from 'react'; +import { useEffect } from 'react'; import { NotiContainer, NotiParagraph, NotiTitle } from './index.css'; import { RelativeModalContainer, Background } from '@/components/Modal/index.css'; import { NotificationCard } from './NotificationCard'; -import { notificationListTest } from '@/assets/data/notificationListTest'; +import { NotificationDto } from '@/api/endpoints/friend/friend.interface'; +import { useNotificationStore } from '@/stores/useNotificationStore'; interface INotification { onCancel: (e: React.MouseEvent) => void; } export const NotificationModal = ({ onCancel }: INotification) => { - const notifications = notificationListTest; + const { + notifications, + newNotificationCount, + resetNotificationCount, + fetchNotifications, + acceptFriend, + rejectFriend, + } = useNotificationStore(); - const [notiList, setNotiList] = useState(notifications); + useEffect(() => { + fetchNotifications(); + resetNotificationCount(); + }, [newNotificationCount]); - const props = { - title: 'Notification', - detail: 'Notification', - confirmText: 'Notification', - onCancel: onCancel, + const handleAccept = async (notification: NotificationDto) => { + try { + await acceptFriend(notification); + } catch (_error) { + alert('친구 수락에 실패했습니다.'); + } }; - const handleAccept = (id: number) => { - console.log(`초대 ID ${id} 수락`); - setNotiList(notiList.filter(noti => noti.id !== id)); // 수락하면 목록에서 제거 - }; - - const handleReject = (id: number) => { - console.log(`초대 ID ${id} 거절`); - setNotiList(notiList.filter(noti => noti.id !== id)); // 거절하면 목록에서 제거 + const handleReject = async (notification: NotificationDto) => { + try { + await rejectFriend(notification); + } catch (_error) { + alert('친구 거절에 실패했습니다.'); + } }; return ( <> - + 알림 - {notiList.length > 0 ? ( + {notifications.length > 0 ? ( <> - {notiList.map(noti => ( + {notifications.map(noti => ( { onMouseLeave={() => setIsHovered(false)} > - {room.title} + { + e.currentTarget.src = DefaultThumbnail; + }} + alt={room.title} + />
diff --git a/src/frontend/src/components/Profile/MyProfile/index.tsx b/src/frontend/src/components/Profile/MyProfile/index.tsx index f0c30910..eefb8e20 100644 --- a/src/frontend/src/components/Profile/MyProfile/index.tsx +++ b/src/frontend/src/components/Profile/MyProfile/index.tsx @@ -29,7 +29,7 @@ export const MyProfile = () => { const navigate = useNavigate(); const [isEditMode, setIsEditMode] = useState(false); const [nickname, setNickname] = useState(user?.nickname); - const [stateMessage, setStateMessage] = useState(user?.stateMessage); + const [stateMessage, setStateMessage] = useState(user?.stateMessage || null); const [isChanged, setIsChanged] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -58,10 +58,24 @@ export const MyProfile = () => { setIsEditMode(false); return; } + if (!nickname) { + setErrorMessage('닉네임을 입력해주세요.'); + return; + } + + if (nickname && (nickname === '' || nickname.length > 20)) { + setErrorMessage('닉네임은 1자 이상 20자 이하여야 합니다.'); + return; + } + + if (stateMessage && (stateMessage === '' || stateMessage.length > 100)) { + setErrorMessage('상태 메시지는 1자 이상 100자 이하여야 합니다.'); + return; + } try { await updateMyProfile({ nickname, - stateMessage, + ...(stateMessage && { stateMessage }), }); setIsEditMode(false); } catch (error) { @@ -126,7 +140,7 @@ export const MyProfile = () => { /> setStateMessage(e.target.value)} placeholder="상태 메시지를 입력하세요" $isEditMode={isEditMode} diff --git a/src/frontend/src/components/RoomDetail/index.tsx b/src/frontend/src/components/RoomDetail/index.tsx index c1aca27f..6cd2e2ea 100644 --- a/src/frontend/src/components/RoomDetail/index.tsx +++ b/src/frontend/src/components/RoomDetail/index.tsx @@ -26,7 +26,12 @@ export const RoomDetail = () => { return ( - + { + e.currentTarget.src = DefaultProfile; + }} + /> {roomInfo?.title} {roomInfo?.creator} diff --git a/src/frontend/src/components/Search/SearchBar/index.tsx b/src/frontend/src/components/Search/SearchBar/index.tsx index cc0783d2..d80c3d58 100644 --- a/src/frontend/src/components/Search/SearchBar/index.tsx +++ b/src/frontend/src/components/Search/SearchBar/index.tsx @@ -1,10 +1,9 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import useDebounceCallback from '@/hooks/utils/useDebounceCallback'; import SearchIcon from '@/assets/img/Search.svg'; import CancelIcon from '@/assets/img/Cancel.svg'; import { SearchBarList } from '@/components/Search/SearchBarList'; -import { searchListSampleTest } from '@/assets/data/searchListTest'; import { CancelIconBox, SearchBarContainer, @@ -12,11 +11,8 @@ import { SearchBarWrapper, SearchIconBox, } from './index.css'; - -interface ISearchList { - id: number; - nickname: string; -} +import { userApi } from '@/api/endpoints/user/user.api'; +import { UserResponseDto } from '@/api/endpoints/user/user.interface'; export const SearchBar = () => { const navigate = useNavigate(); @@ -27,10 +23,15 @@ export const SearchBar = () => { const [targetIndex, setTargetIndex] = useState(-1); const [searchValue, setSearchValue] = useState(''); const [totalLength, setTotalLength] = useState(0); - const [searchList, setSearchList] = useState([]); + const [searchList, setSearchList] = useState([]); const enterKeyProcessed = useRef(false); - const searchListSample = searchListSampleTest; + useEffect(() => { + if (targetIndex !== -1) { + searchInput.current!.value = searchList[targetIndex]?.nickname; + setSearchValue(searchInput.current!.value); + } + }, [targetIndex]); const resetSearchState = () => { setSearchList([]); @@ -42,7 +43,7 @@ export const SearchBar = () => { } }; - const handleSearchInput = () => { + const handleSearchInput = async () => { if (searchInput.current) { setSearchValue(searchInput.current.value); const searchValue = searchInput.current.value; @@ -52,9 +53,9 @@ export const SearchBar = () => { return; } else { // 검색어에 따른 검색 결과 리스트 - const searchListData = searchListSample.filter(item => item.nickname.includes(searchValue)); - setSearchList(searchListData); - setTotalLength(searchListData.length); + const searchListData = await userApi.getUsers(0, 30, searchValue); + setSearchList(searchListData.users); + setTotalLength(searchListData.totalLength); } } }; diff --git a/src/frontend/src/components/Search/SearchBarList/index.tsx b/src/frontend/src/components/Search/SearchBarList/index.tsx index 85f555eb..68bfef24 100644 --- a/src/frontend/src/components/Search/SearchBarList/index.tsx +++ b/src/frontend/src/components/Search/SearchBarList/index.tsx @@ -1,11 +1,6 @@ import { SearchListItem } from '@/components/Search/SearchBarListItem'; import { TotalLi, Ul } from './index.css'; - -interface ISearchList { - id: number; - nickname: string; -} - +import { UserResponseDto } from '@/api/endpoints/user/user.interface'; export const SearchBarList = ({ searchList, searchWord, @@ -13,7 +8,7 @@ export const SearchBarList = ({ totalLength, targetIndex, }: { - searchList: ISearchList[]; + searchList: UserResponseDto[]; resetSearchState: () => void; searchWord?: string; totalLength: number; diff --git a/src/frontend/src/components/Search/SearchItem/index.css.ts b/src/frontend/src/components/Search/SearchItem/index.css.ts new file mode 100644 index 00000000..93207f92 --- /dev/null +++ b/src/frontend/src/components/Search/SearchItem/index.css.ts @@ -0,0 +1,38 @@ +import { styled } from 'styled-components'; + +export const SearchItemContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0; + border-bottom: 1px solid var(--palette-line-normal-alternative); +`; + +export const SearchItemContent = styled.div` + display: flex; + align-items: center; +`; + +export const SearchItemProfileImage = styled.div` + width: 40px; + height: 40px; + border-radius: 10px; + overflow: hidden; + margin-right: 1rem; +`; + +export const SearchItemInfo = styled.div` + display: flex; + flex-direction: column; +`; + +export const SearchItemNickname = styled.div` + font-size: 1.125rem; + font-weight: 600; +`; + +export const SearchItemStateMessage = styled.div` + font-size: 0.875rem; + color: var(--palette-font-gray-strong); + margin-top: 0.25rem; +`; diff --git a/src/frontend/src/components/Search/SearchItem/index.tsx b/src/frontend/src/components/Search/SearchItem/index.tsx new file mode 100644 index 00000000..1df628a5 --- /dev/null +++ b/src/frontend/src/components/Search/SearchItem/index.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; +import AddUser from '@/assets/img/AddUser.svg'; +import { CircleButton } from '@/components/IconButton/index.css'; +import { friendApi } from '@/api/endpoints/friend/friend.api'; +import { useUserStore } from '@/stores/useUserStore'; +import Check from '@/assets/img/Check.svg'; +import { useFriendStore } from '@/stores/useFriendStore'; +import UsersFill from '@/assets/img/UsersFill.svg'; +import { useNotificationStore } from '@/stores/useNotificationStore'; +import { + SearchItemContainer, + SearchItemContent, + SearchItemProfileImage, + SearchItemInfo, + SearchItemNickname, + SearchItemStateMessage, +} from './index.css'; +import { UserResponseDto } from '@/api/endpoints/user/user.interface'; +import axios from 'axios'; + +export const SearchItem = ({ user }: { user: UserResponseDto }) => { + const { user: me } = useUserStore(); + const { friends } = useFriendStore(); + const { notifications } = useNotificationStore(); + const [alreadyRequested, setAlreadyRequested] = useState(false); + + const isFriend = friends.some(friend => friend.friend_id === user.userId); + const hasRequested = notifications.some( + notification => notification.senderId === user.userId && notification.status === 'PENDING', + ); + + useEffect(() => { + if (hasRequested) { + setAlreadyRequested(true); + } + }, [hasRequested]); + + const handleAddFriend = async () => { + if (!me || isFriend || alreadyRequested || hasRequested) return; + + try { + await friendApi.requestFriend(me.userId, user.userId); + setAlreadyRequested(true); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data?.detail) { + const errorMessage = error.response.data.detail; + if (errorMessage === '이미 친구 요청을 보냈습니다.') { + setAlreadyRequested(true); + } + alert(errorMessage); + } else { + alert('알 수 없는 오류가 발생했습니다.'); + } + } + }; + + return ( + + + + { + e.currentTarget.src = DefaultProfile; + }} + alt="profile" + /> + + + {user.nickname} + {user.stateMessage && ( + {user.stateMessage} + )} + + + {me && me.userId !== user.userId && ( + +
+ 친구 추가 +
+
+ )} +
+ ); +}; diff --git a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx index 528fb096..c0236598 100644 --- a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx @@ -7,7 +7,13 @@ import DefaultProfile from '@/assets/img/DefaultProfile.svg'; export const ChatLayout = ({ message }: { message: ReceiveMessageDto }) => { return ( - + { + e.currentTarget.src = DefaultProfile; + }} + /> <ChatNickname role={message.role} nickname={message.nickname ?? '알수없음'} /> diff --git a/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx b/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx index 74d2bb2c..f2e79b11 100644 --- a/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx +++ b/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx @@ -4,7 +4,7 @@ import { ButtonColor } from '@/types/enums/ButtonColor'; import Trashcan from '@/assets/img/Trashcan.svg'; import ArrowUp from '@/assets/img/ArrowUp.svg'; import ArrowDown from '@/assets/img/ArrowDown.svg'; - +import DefaultThumbnail from '@/assets/img/DefaultThumbnail.svg'; import { Container, Thumbnail, @@ -48,7 +48,13 @@ export const PlaylistItem = (props: IPlaylistItem) => { $isDragging={props.isDragging} $isPreview={props.isPreview} > - <Thumbnail src={props.video.thumbnail} alt={`Video ${props.video.id}`} /> + <Thumbnail + src={props.video.thumbnail} + alt={`Video ${props.video.id}`} + onError={e => { + e.currentTarget.src = DefaultThumbnail; + }} + /> <PreviewInfo> <Playlist__Title>{props.video.title || '제목 없음'}</Playlist__Title> <Playlist__Youtuber>{props.video.youtuber || '유튜버 정보 없음'}</Playlist__Youtuber> diff --git a/src/frontend/src/components/Sidebar/Playlist/index.tsx b/src/frontend/src/components/Sidebar/Playlist/index.tsx index 734b3d33..9196e4a3 100644 --- a/src/frontend/src/components/Sidebar/Playlist/index.tsx +++ b/src/frontend/src/components/Sidebar/Playlist/index.tsx @@ -18,6 +18,7 @@ import { import { ButtonColor } from '@/types/enums/ButtonColor'; import { useDebounce } from '@/hooks/utils/useDebounce'; import { PlaylistItem } from './PlaylistItem'; +import DefaultThumbnail from '@/assets/img/DefaultThumbnail.svg'; import { useWebSocketStore } from '@/stores/useWebSocketStore'; import { useUserStore } from '@/stores/useUserStore'; import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; @@ -331,7 +332,13 @@ export const Playlist = () => { width="100%" justifycontent="flex-start" > - <PreviewImg src={thumbnailPreview} /> + <PreviewImg + src={thumbnailPreview} + alt="thumbnail" + onError={e => { + e.currentTarget.src = DefaultThumbnail; + }} + /> <PreviewInfo> <PreviewInfo__Title>{videoTitle || '제목 없음'}</PreviewInfo__Title> <PreviewInfo__Youtuber>{videoYoutuber || '유튜버 정보 없음'}</PreviewInfo__Youtuber> diff --git a/src/frontend/src/components/TopNavBar/index.css.ts b/src/frontend/src/components/TopNavBar/index.css.ts index 5d7ee737..4a3741c3 100644 --- a/src/frontend/src/components/TopNavBar/index.css.ts +++ b/src/frontend/src/components/TopNavBar/index.css.ts @@ -7,6 +7,7 @@ export const Wrapper = styled.div` justify-content: space-between; align-items: center; padding: 0 30px; + border-bottom: 1px solid var(--palette-line-solid-neutral); `; export const LogoBox = styled.div` @@ -41,6 +42,22 @@ export const ButtonBox = styled.div` } `; +export const NotificationCount = styled.div` + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: var(--palette-status-negative); + color: var(--palette-static-white); + font-size: 0.75rem; + font-weight: 600; + display: flex; + justify-content: center; + align-items: center; +`; + export const LoginButton = styled.button` height: 32px; font-size: 1rem; diff --git a/src/frontend/src/components/TopNavBar/index.tsx b/src/frontend/src/components/TopNavBar/index.tsx index a5460cdc..9f64d2b7 100644 --- a/src/frontend/src/components/TopNavBar/index.tsx +++ b/src/frontend/src/components/TopNavBar/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; import axios from 'axios'; -import { Link } from 'react-router-dom'; import AddCircleIcon from '@/assets/img/AddCircle.svg'; import BellIcon from '@/assets/img/Bell.svg'; import { @@ -10,6 +10,7 @@ import { LogoBox, LoginButton, ProfileButton, + NotificationCount, } from './index.css'; import { LogoButton } from '@/components/common/LogoButton'; import DefaultProfile from '@/assets/img/DefaultProfile.svg'; @@ -21,14 +22,28 @@ import { useUserStore } from '@/stores/useUserStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMyRoomsStore } from '@/stores/useMyRoomsStore'; import { useWebSocketStore } from '@/stores/useWebSocketStore'; + +import { friendApi } from '@/api/endpoints/friend/friend.api'; +import { useFriendStore } from '@/stores/useFriendStore'; +import { useNotificationStore } from '@/stores/useNotificationStore'; + import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; import { useVideoStore } from '@/stores/useVideoStore'; const API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY as string; + export const TopNavBar = () => { + const navigate = useNavigate(); const { user, fetchMyProfile, clearProfile } = useUserStore(); const { fetchMyRooms } = useMyRoomsStore(); + const { fetchFriends } = useFriendStore(); + const { + newNotificationCount, + increaseNotificationCount, + resetNotificationCount, + fetchNotifications, + } = useNotificationStore(); const { connect, subscribeRoomPlaylistUpdate } = useWebSocketStore(); const { currentRoom } = useCurrentRoomStore(); const { setVideoQueue } = useVideoStore(); @@ -48,8 +63,19 @@ export const TopNavBar = () => { } const initializeUser = async () => { - await fetchMyProfile(); - await fetchMyRooms(); + try { + await fetchMyProfile(); + const [_rooms, _friends, _notifications, unreadData] = await Promise.all([ + fetchMyRooms(), + fetchFriends(), + fetchNotifications(), + friendApi.getUnreadNotificationsCount(), + ]); + increaseNotificationCount(unreadData.unread_count); + console.log('⭐️initializeUser⭐️'); + } catch (error) { + console.error('Error initializing user:', error); + } }; initializeUser(); }, [accessToken, clearProfile, fetchMyProfile, fetchMyRooms, connect]); @@ -118,9 +144,23 @@ export const TopNavBar = () => { }); }, [roomId, subscribeRoomPlaylistUpdate, setVideoQueue]); + const clickCreateRoom = (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation(); + if (!user) { + navigate('/login'); + return; + } + setIsRoomCreateModalOpen(true); + }; + const clickNotification = (e: React.MouseEvent<HTMLDivElement>) => { e.stopPropagation(); + if (!user) { + navigate('/login'); + return; + } setIsNotificationModalOpen(true); + resetNotificationCount(); }; const handleCancelNotification = (e: React.MouseEvent<HTMLDivElement>) => { @@ -145,12 +185,15 @@ export const TopNavBar = () => { </LogoBox> <SearchBar /> <ButtonContainer> - <ButtonBox onClick={() => setIsRoomCreateModalOpen(true)}> + <ButtonBox onClick={clickCreateRoom}> <img src={AddCircleIcon} alt="Create Room" /> </ButtonBox> <ButtonBox onClick={clickNotification}> <img src={BellIcon} alt="Notification" /> {isNotificationModalOpen && <NotificationModal onCancel={handleCancelNotification} />} + {newNotificationCount > 0 && ( + <NotificationCount>{newNotificationCount}</NotificationCount> + )} </ButtonBox> {user ? ( <ProfileButton onClick={clickProfile}> diff --git a/src/frontend/src/components/VideoCard/index.tsx b/src/frontend/src/components/VideoCard/index.tsx index e759d01c..37ae6a62 100644 --- a/src/frontend/src/components/VideoCard/index.tsx +++ b/src/frontend/src/components/VideoCard/index.tsx @@ -20,11 +20,23 @@ export const VideoCard = ({ video, onClick }: IVideoCard) => { <VideoCardContainer onClick={onClick}> <Thumbnail> <UserCount>{video.userCount}명</UserCount> - <img src={video.playlistUrl ? video.playlistUrl : DefaultThumbnail} alt={video.title} /> + <img + src="asdfasdofijasdo" + onError={e => { + e.currentTarget.src = DefaultThumbnail; + }} + alt={video.title} + /> </Thumbnail> <VideoInfo> <Profile> - <img src={video.profileImageUrl ?? DefaultProfile} alt={video.creator} /> + <img + src={video.profileImageUrl ?? DefaultProfile} + onError={e => { + e.currentTarget.src = DefaultProfile; + }} + alt={video.creator} + /> </Profile> <div> <Title className="clamp-2">{video.title} diff --git a/src/frontend/src/components/common/AlertDescription/index.css.ts b/src/frontend/src/components/common/AlertDescription/index.css.ts new file mode 100644 index 00000000..3fd9942d --- /dev/null +++ b/src/frontend/src/components/common/AlertDescription/index.css.ts @@ -0,0 +1,31 @@ +import { styled } from 'styled-components'; + +export const AlertDescriptionContainer = styled.div` + display: flex; + gap: 0.5rem; + border: 1px solid var(--palette-line-normal-normal); + background-color: var(--palette-background-normal-alternative); + border-radius: 10px; + padding: 1.5rem; +`; + +export const AlertDescriptionImage = styled.div` + width: 1rem; + height: 1rem; + + & > img { + width: 100%; + height: 100%; + } +`; + +export const AlertDescriptionTitle = styled.h1` + font-size: 1.25rem; + font-weight: 600; +`; + +export const AlertDescriptionTextBox = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; diff --git a/src/frontend/src/components/common/AlertDescription/index.tsx b/src/frontend/src/components/common/AlertDescription/index.tsx new file mode 100644 index 00000000..a768050a --- /dev/null +++ b/src/frontend/src/components/common/AlertDescription/index.tsx @@ -0,0 +1,21 @@ +import { CommonParagraph } from '@/ui/Common.css'; +import { + AlertDescriptionContainer, + AlertDescriptionImage, + AlertDescriptionTextBox, + AlertDescriptionTitle, +} from './index.css'; +import CircleInformation from '@/assets/img/CircleInformation.svg'; +export const AlertDescription = (props: { title: string; description: string }) => { + return ( + + + alert-description-image + + + {props.title} + {props.description} + + + ); +}; diff --git a/src/frontend/src/components/common/ProfileDetail/ProfileHeader/index.tsx b/src/frontend/src/components/common/ProfileDetail/ProfileHeader/index.tsx index d46953e5..49c9b3c6 100644 --- a/src/frontend/src/components/common/ProfileDetail/ProfileHeader/index.tsx +++ b/src/frontend/src/components/common/ProfileDetail/ProfileHeader/index.tsx @@ -11,7 +11,7 @@ import { Profile__Header__Img, Profile__Header__ButtonContainer, } from '../index.css'; - +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; interface IProfileHeader { sidebarType: SidebarType; imgUrl: string; @@ -20,7 +20,12 @@ interface IProfileHeader { export const ProfileHeader = (props: IProfileHeader) => { return ( - + { + e.currentTarget.src = DefaultProfile; + }} + /> {props.sidebarType === SidebarType.VOICECHAT ? ( <> diff --git a/src/frontend/src/components/common/SmallProfile/index.tsx b/src/frontend/src/components/common/SmallProfile/index.tsx index 2266d418..b02a2512 100644 --- a/src/frontend/src/components/common/SmallProfile/index.tsx +++ b/src/frontend/src/components/common/SmallProfile/index.tsx @@ -4,6 +4,7 @@ import MicrophoneOffRed from '@/assets/img/MicrophoneOffRed.svg'; import HeadphoneOffRed from '@/assets/img/HeadphoneOffRed.svg'; import { UserRole } from '@/types/enums/UserRole'; import { ProfileType } from '@/types/enums/ProfileType'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; interface ISmallProfile { type: ProfileType; @@ -32,7 +33,12 @@ export const SmallProfile = (props: ISmallProfile) => { return ( - + { + e.currentTarget.src = DefaultProfile; + }} + /> {renderBtn()} diff --git a/src/frontend/src/components/common/Toast/index.css.ts b/src/frontend/src/components/common/Toast/index.css.ts new file mode 100644 index 00000000..303cb5a9 --- /dev/null +++ b/src/frontend/src/components/common/Toast/index.css.ts @@ -0,0 +1,70 @@ +import styled, { keyframes } from 'styled-components'; + +export const fadeIn = keyframes` + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +`; + +export const ToastContainer = styled.div` + position: fixed; + bottom: 20px; + left: 90px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 9999; +`; + +interface ToastProps { + $type: 'info' | 'success' | 'error'; +} + +export const Toast = styled.div` + background: ${({ $type }) => + $type === 'success' ? '#4caf50' : $type === 'error' ? '#f44336' : '#333'}; + color: white; + padding: 0.75rem 1rem; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + animation: ${fadeIn} 0.3s ease-in-out; + min-width: 250px; + &:hover button.close-button { + color: var(--palette-line-normal-normal); + } +`; + +export const ButtonContainer = styled.div` + display: flex; + gap: 8px; + margin-left: 1rem; +`; + +export const ToastButton = styled.button` + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + &:hover { + background: rgba(255, 255, 255, 0.4); + } +`; + +export const CloseButton = styled.button` + background: transparent; + border: none; + color: var(--palette-line-normal-normal); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + + &:hover { + color: white !important; + } +`; diff --git a/src/frontend/src/components/common/Toast/index.tsx b/src/frontend/src/components/common/Toast/index.tsx new file mode 100644 index 00000000..ddaacb8e --- /dev/null +++ b/src/frontend/src/components/common/Toast/index.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; +import { X } from 'lucide-react'; +import { useToastStore } from '@/stores/useToastStore'; +import { ButtonContainer, CloseButton, Toast, ToastButton, ToastContainer } from './index.css'; + +interface ToastProviderProps { + children: ReactNode; +} + +export const ToastProvider: React.FC = ({ children }) => { + const { toasts, removeToast } = useToastStore(); + + return ( + <> + {children} + + {toasts.map(({ id, message, type, buttons }) => ( + + {message} + + {buttons?.map((button, index) => ( + + {button.label} + + ))} + + removeToast(id)} className="close-button"> + + + + ))} + + + ); +}; diff --git a/src/frontend/src/hooks/queries/useAuth.ts b/src/frontend/src/hooks/queries/useAuth.ts index 504acc8a..7e86a1f0 100644 --- a/src/frontend/src/hooks/queries/useAuth.ts +++ b/src/frontend/src/hooks/queries/useAuth.ts @@ -2,6 +2,11 @@ import { useMutation } from '@tanstack/react-query'; import { authApi } from '@/api/endpoints/auth/auth.api'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useWebSocketStore } from '@/stores/useWebSocketStore'; +import { useUserStore } from '@/stores/useUserStore'; +import { useMyRoomsStore } from '@/stores/useMyRoomsStore'; +import { useFriendStore } from '@/stores/useFriendStore'; +import { useNotificationStore } from '@/stores/useNotificationStore'; export const useAuth = () => { const navigate = useNavigate(); @@ -19,8 +24,16 @@ export const useAuth = () => { const logout = useMutation({ mutationFn: authApi.logout, - onSuccess: () => { + onError: (error: Error) => { + console.error('Logout failed:', error.message); + }, + onSettled: () => { useAuthStore.getState().clear(); + useFriendStore.getState().clear(); + useNotificationStore.getState().clear(); + useMyRoomsStore.getState().clearMyRooms(); + useUserStore.getState().clearProfile(); + useWebSocketStore.getState().disconnect(); navigate('/'); }, }); diff --git a/src/frontend/src/hooks/queries/useFriend.ts b/src/frontend/src/hooks/queries/useFriend.ts new file mode 100644 index 00000000..87388eef --- /dev/null +++ b/src/frontend/src/hooks/queries/useFriend.ts @@ -0,0 +1,12 @@ +import { useFriendStore } from '@/stores/useFriendStore'; +import { useQuery } from '@tanstack/react-query'; + +export const useFriend = () => { + const { fetchFriends } = useFriendStore(); + + return useQuery({ + queryKey: ['friends'], + queryFn: () => fetchFriends(), + staleTime: 1000, + }); +}; diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 65e0106b..e761c001 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -2,9 +2,12 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { ToastProvider } from './components/common/Toast/index.tsx'; createRoot(document.getElementById('root')!).render( // - , + + , + , // , ); diff --git a/src/frontend/src/pages/FriendPage/FriendSkeleton/index.tsx b/src/frontend/src/pages/FriendPage/FriendSkeleton/index.tsx new file mode 100644 index 00000000..bb6f27d0 --- /dev/null +++ b/src/frontend/src/pages/FriendPage/FriendSkeleton/index.tsx @@ -0,0 +1,67 @@ +// ... existing imports ... +import { FriendCardContainer } from '@/components/Friend/FriendCard/index.css'; +import { Container, SkeletonBase, SubTitle, Title, Wrapper } from '@/ui/Common.css'; +import { FriendGrid } from '../index.css'; +import styled from 'styled-components'; + +export const FriendSkeleton = () => { + return ( + + + 친구 + 친구 목록 + + {[...Array(9)].map((_, index) => ( + + ))} + + + + ); +}; + +const FriendCardSkeleton = () => { + return ( + + + + + + + + + + ); +}; + +const SkeletonCard = styled(SkeletonBase)` + background: var(--palette-static-white); + border-radius: 0.625rem; + animation: shimmer 1.5s infinite; +`; + +const SkeletonProfile = styled(SkeletonBase)` + flex-shrink: 0; + width: 3rem; + height: 3rem; + border-radius: 10px; +`; + +const SkeletonTextContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +`; + +const SkeletonTitle = styled(SkeletonBase)` + width: 80%; + height: 1.5rem; + border-radius: 5px; +`; + +const SkeletonText = styled(SkeletonBase)` + width: 50%; + height: 1rem; + border-radius: 5px; +`; diff --git a/src/frontend/src/pages/FriendPage/NoFriendView/index.tsx b/src/frontend/src/pages/FriendPage/NoFriendView/index.tsx new file mode 100644 index 00000000..0a4a1078 --- /dev/null +++ b/src/frontend/src/pages/FriendPage/NoFriendView/index.tsx @@ -0,0 +1,23 @@ +import PeopleSearch from '@/assets/img/PeopleSearch.svg'; +import { + GreetingViewContainer, + GreetingViewImage, + GreetingViewSubTitle, + GreetingViewTitle, + GreetingViewWrapper, +} from '@/ui/Common.css'; + +export const NoFriendView = () => { + + return ( + + + + greeting-view-image + + 아직 친구가 없어요 + 새로운 친구를 추가하고 함께 대화를 나눠보세요! + + + ); +}; diff --git a/src/frontend/src/pages/FriendPage/index.css.ts b/src/frontend/src/pages/FriendPage/index.css.ts new file mode 100644 index 00000000..56718be7 --- /dev/null +++ b/src/frontend/src/pages/FriendPage/index.css.ts @@ -0,0 +1,7 @@ +import { styled } from "styled-components"; + +export const FriendGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 10px; +`; diff --git a/src/frontend/src/pages/FriendPage/index.tsx b/src/frontend/src/pages/FriendPage/index.tsx index f9772688..2c36c677 100644 --- a/src/frontend/src/pages/FriendPage/index.tsx +++ b/src/frontend/src/pages/FriendPage/index.tsx @@ -1,3 +1,43 @@ +import { NoFriendView } from './NoFriendView'; +import { useUserStore } from '@/stores/useUserStore'; +import { useEffect } from 'react'; +import { Wrapper, Container, SubTitle, Title } from '@/ui/Common.css'; +import { FriendCard } from '@/components/Friend/FriendCard'; +import { FriendGrid } from './index.css'; +import { FriendSkeleton } from './FriendSkeleton'; +import { useDelayedLoading } from '@/hooks/utils/useDelayedLoading'; +import { useFriendStore } from '@/stores/useFriendStore'; + export const FriendPage = () => { - return
Friend
; + const { user } = useUserStore(); + const { friends, fetchFriends } = useFriendStore(); + const showSkeleton = useDelayedLoading(friends); + + useEffect(() => { + if (user) { + fetchFriends(); + } + }, [user]); + + if (showSkeleton) { + return ; + } + + if (friends.length === 0) { + return ; + } + + return ( + + + 친구 + 친구 목록 + + {friends.map(friend => ( + + ))} + + + + ); }; diff --git a/src/frontend/src/pages/HomePage/GreetingView/index.tsx b/src/frontend/src/pages/HomePage/GreetingView/index.tsx new file mode 100644 index 00000000..58c1d808 --- /dev/null +++ b/src/frontend/src/pages/HomePage/GreetingView/index.tsx @@ -0,0 +1,48 @@ +import { CommonButton } from '@/components/common/Button'; +import { ButtonColor } from '@/types/enums/ButtonColor'; +import ViewVideo from '@/assets/img/ViewVideo.svg'; +import { + GreetingViewContainer, + GreetingViewImage, + GreetingViewSubTitle, + GreetingViewTitle, + GreetingViewWrapper, +} from '@/ui/Common.css'; +import { useState } from 'react'; +import { RoomCreateModal } from '@/components/Modal/RoomCreateModal'; + +export const GreetingView = () => { + const [isRoomCreateModalOpen, setIsRoomCreateModalOpen] = useState(false); + + const handleCreateRoom = () => { + setIsRoomCreateModalOpen(true); + }; + + return ( + <> + + + + greeting-view-image + + 실시간 비디오 채팅을 시작해보세요 + + 지금 바로 새로운 방을 만들어 대화를 시작해보세요. + + + 방 만들기 + + + + {isRoomCreateModalOpen && ( + setIsRoomCreateModalOpen(false)} /> + )} + + ); +}; diff --git a/src/frontend/src/pages/HomePage/index.tsx b/src/frontend/src/pages/HomePage/index.tsx index 81223878..48cf502c 100644 --- a/src/frontend/src/pages/HomePage/index.tsx +++ b/src/frontend/src/pages/HomePage/index.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import { RoomDto } from '@/api/endpoints/room/room.interface'; import { HomeSkeleton } from './HomeSkeleton'; import { useDelayedLoading } from '@/hooks/utils/useDelayedLoading'; - +import { GreetingView } from './GreetingView'; export const HomePage = () => { const navigate = useNavigate(); const getRooms = useRooms(); @@ -27,6 +27,10 @@ export const HomePage = () => { return ; } + if (videos.length === 0) { + return ; + } + return ( {videos.map(video => ( diff --git a/src/frontend/src/pages/LoginPage/index.tsx b/src/frontend/src/pages/LoginPage/index.tsx index 83dd10c1..c1a214c7 100644 --- a/src/frontend/src/pages/LoginPage/index.tsx +++ b/src/frontend/src/pages/LoginPage/index.tsx @@ -7,6 +7,8 @@ import { Wrapper, CommonInput, IdSaveCheckBox, LinkBox, SubTitle } from './index import { useAuth } from '@/hooks/queries/useAuth'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { useLocalStorage } from '@/hooks/utils/useLocalStorage'; +import { AxiosError } from 'axios'; +import { getErrorMessage } from '@/utils/errorUtils'; export const LoginPage = () => { const emailRef = useRef(null); @@ -33,8 +35,15 @@ export const LoginPage = () => { login.mutate( { email, password }, { - onError: () => { - alert('이메일 또는 비밀번호가 일치하지 않습니다.'); + onError: (error: Error) => { + const axiosError = error as AxiosError; + if (axiosError.response?.status === 401) { + alert('이메일 또는 비밀번호가 일치하지 않습니다.'); + } else if (axiosError.response?.status === 429) { + alert('너무 많은 요청을 보내셨습니다. 잠시 후 다시 시도해주세요.'); + } else { + alert(getErrorMessage(axiosError)); + } }, }, ); diff --git a/src/frontend/src/pages/MyRoomPage/index.tsx b/src/frontend/src/pages/MyRoomPage/index.tsx index 4b2e7227..33f58ebd 100644 --- a/src/frontend/src/pages/MyRoomPage/index.tsx +++ b/src/frontend/src/pages/MyRoomPage/index.tsx @@ -1,11 +1,12 @@ import { useEffect } from 'react'; import { MyRoomCard } from '@/components/MyRoomCard'; -import { Wrapper, Container, Title, SubTitle, CommonParagraph } from '@/ui/Common.css'; +import { Wrapper, Container, Title, SubTitle } from '@/ui/Common.css'; import { useUserStore } from '@/stores/useUserStore'; import { MyRoomSkeleton } from './MyRoomSkeleton'; import { useDelayedLoading } from '@/hooks/utils/useDelayedLoading'; import { useMyRooms } from '@/hooks/queries/useMyRooms'; import { useMyRoomsStore } from '@/stores/useMyRoomsStore'; +import { AlertDescription } from '@/components/common/AlertDescription'; export const MyRoomPage = () => { const { data } = useMyRooms(); @@ -36,7 +37,10 @@ export const MyRoomPage = () => { .filter(room => room.creator === user?.nickname) .map(room => ) ) : ( - 🥹 내가 만든 방이 없습니다. + )}
@@ -46,7 +50,10 @@ export const MyRoomPage = () => { .filter(room => room.creator !== user?.nickname) .map(room => ) ) : ( - 🥹 참여 중인 방이 없습니다. + )}
diff --git a/src/frontend/src/pages/SearchPage/index.tsx b/src/frontend/src/pages/SearchPage/index.tsx index 898bc64c..d5ec167e 100644 --- a/src/frontend/src/pages/SearchPage/index.tsx +++ b/src/frontend/src/pages/SearchPage/index.tsx @@ -1,18 +1,36 @@ +import { userApi } from '@/api/endpoints/user/user.api'; +import { UserResponseDto } from '@/api/endpoints/user/user.interface'; +import { SearchItem } from '@/components/Search/SearchItem'; +import { Container, SubTitle, Title, Wrapper } from '@/ui/Common.css'; +import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { styled } from 'styled-components'; export const SearchPage = () => { const [searchParams] = useSearchParams(); const query = searchParams.get('q'); // URL에서 q 값 가져오기 + const [searchList, setSearchList] = useState([]); + const [totalLength, setTotalLength] = useState(0); + useEffect(() => { + if (query) { + const fetchSearchList = async () => { + const searchListData = await userApi.getUsers(0, 100, query); + console.log('searchListData: ', searchListData); + setSearchList(searchListData.users); + setTotalLength(searchListData.totalLength); + }; + fetchSearchList(); + } + }, [query]); return ( -
-

검색 결과

- 검색어: {query || '검색어가 없습니다.'} -
+ + + 검색 + 검색 결과 {totalLength}개 + {searchList.map(user => ( + + ))} + + ); }; - -const Pharagraph = styled.span` - background-color: gray; -`; diff --git a/src/frontend/src/stores/useFriendStore.ts b/src/frontend/src/stores/useFriendStore.ts new file mode 100644 index 00000000..28b0f387 --- /dev/null +++ b/src/frontend/src/stores/useFriendStore.ts @@ -0,0 +1,37 @@ +import { FriendDto } from '@/api/endpoints/friend/friend.interface'; +import { friendApi } from '@/api/endpoints/friend/friend.api'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface FriendStore { + friends: FriendDto[]; + fetchFriends: () => Promise; + updateFriendStatus: (friendId: number, status: string) => void; + clear: () => void; +} + +export const useFriendStore = create( + persist( + set => ({ + friends: [], + fetchFriends: async () => { + const data = await friendApi.getFriends(); + set({ friends: data }); + return data; + }, + updateFriendStatus: (friendId: number, status: string) => { + set(state => ({ + friends: state.friends.map(friend => + friend.friend_id === friendId ? { ...friend, status } : friend, + ), + })); + }, + clear: () => { + set({ friends: [] }); + }, + }), + { + name: 'friend-storage', + }, + ), +); diff --git a/src/frontend/src/stores/useNotificationStore.ts b/src/frontend/src/stores/useNotificationStore.ts new file mode 100644 index 00000000..8e135ac6 --- /dev/null +++ b/src/frontend/src/stores/useNotificationStore.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand'; +import { NotificationDto, NotificationStatus } from '@/api/endpoints/friend/friend.interface'; +import { persist } from 'zustand/middleware'; +import { friendApi } from '@/api/endpoints/friend/friend.api'; +import { useFriendStore } from './useFriendStore'; + +interface NotificationStore { + notifications: NotificationDto[]; + newNotificationCount: number; + fetchNotifications: () => Promise; + acceptFriend: (notification: NotificationDto) => Promise; + rejectFriend: (notification: NotificationDto) => Promise; + updateNotificationStatus: (timestamp: number, status: NotificationStatus) => void; + increaseNotificationCount: (n?: number) => void; + decreaseNotificationCount: (n?: number) => void; + resetNotificationCount: () => void; + clear: () => void; +} + +export const useNotificationStore = create( + persist( + (set, get) => ({ + notifications: [], + newNotificationCount: 0, + // 알림 조회 + fetchNotifications: async () => { + const notifications = await friendApi.getNotifications(); + set({ notifications }); + }, + + // 친구 수락 + acceptFriend: async (notification: NotificationDto) => { + try { + await friendApi.acceptFriend(notification.receiverId, notification.senderId); + get().updateNotificationStatus(notification.timestamp, 'ACCEPTED'); + useFriendStore.getState().fetchFriends(); + } catch (error) { + console.error(error); + } + }, + + // 친구 거절 + rejectFriend: async (notification: NotificationDto) => { + try { + await friendApi.rejectFriend(notification.receiverId, notification.senderId); + get().updateNotificationStatus(notification.timestamp, 'REJECTED'); + useFriendStore.getState().fetchFriends(); + } catch (error) { + console.error(error); + } + }, + + // 알림 업데이트 처리 + updateNotificationStatus: (timestamp: number, status: NotificationStatus) => { + set(state => ({ + notifications: state.notifications.map(noti => + noti.timestamp === timestamp ? { ...noti, status } : noti, + ), + })); + }, + + increaseNotificationCount: (n: number = 1) => { + set(state => ({ newNotificationCount: state.newNotificationCount + n })); + }, + + decreaseNotificationCount: (n: number = 1) => { + set(state => ({ newNotificationCount: state.newNotificationCount - n })); + }, + + resetNotificationCount: () => { + set({ newNotificationCount: 0 }); + }, + + clear: () => { + set({ notifications: [] }); + }, + }), + { + name: 'notification-storage', + }, + ), +); diff --git a/src/frontend/src/stores/useToastStore.ts b/src/frontend/src/stores/useToastStore.ts new file mode 100644 index 00000000..f460cdae --- /dev/null +++ b/src/frontend/src/stores/useToastStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; + +interface ToastButton { + label: string; + onClick: () => void; +} + +interface Toast { + id: number; + message: string; + type: 'info' | 'success' | 'error'; + duration?: number; + buttons?: ToastButton[]; +} + +interface ToastStore { + toasts: Toast[]; + addToast: (message: string, type?: 'info' | 'success' | 'error', duration?: number, buttons?: ToastButton[], id?: number) => void; + removeToast: (id: number) => void; +} + +export const useToastStore = create((set) => ({ + toasts: [], + addToast: (message, type = 'info', duration = 3000, buttons, id = Date.now()) => { + set((state) => ({ toasts: [...state.toasts, { id, message, type, duration, buttons }] })); + + if (duration) { + setTimeout(() => { + set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })); + }, duration); + } + }, + removeToast: (id) => { + set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })); + }, +})); diff --git a/src/frontend/src/stores/useUserStore.ts b/src/frontend/src/stores/useUserStore.ts index eb1e039c..bcb7ec70 100644 --- a/src/frontend/src/stores/useUserStore.ts +++ b/src/frontend/src/stores/useUserStore.ts @@ -22,7 +22,6 @@ export const useUserStore = create( fetchMyProfile: async () => { try { const data = await userApi.getMyProfile(); - console.log(data); set({ user: data }); return data; } catch (error) { diff --git a/src/frontend/src/stores/useWebSocketStore.ts b/src/frontend/src/stores/useWebSocketStore.ts index 85226410..0a49c645 100644 --- a/src/frontend/src/stores/useWebSocketStore.ts +++ b/src/frontend/src/stores/useWebSocketStore.ts @@ -9,6 +9,11 @@ import SockJS from 'sockjs-client'; import Stomp, { Client } from 'stompjs'; import { create } from 'zustand'; import { useUserStore } from './useUserStore'; +import { FriendConnectionMessage } from '@/types/dto/Friend.dto'; +import { useFriendStore } from './useFriendStore'; +import { useNotificationStore } from './useNotificationStore'; +import { useToastStore } from './useToastStore'; +import { NotificationDto } from '@/api/endpoints/friend/friend.interface'; interface WebSocketStore { socket: WebSocket | null; @@ -20,9 +25,10 @@ interface WebSocketStore { disconnect: () => void; subTopic: (destination: string, callback: (message: T) => void) => void; - subscribeInvitations: (userId: number) => void; - subscribeFriendConnection: (userId: number) => void; - subscribeRoomChat: (roomId: number) => void; + subscribeRoom: (roomId: number) => void; + subscribeRooms: (roomIds: number[]) => void; + subscribeInvitations: (userId: number, callback: (message: T) => void) => void; + subscribeFriendConnection: (userId: number, callback: (message: T) => void) => void; subscribeRoomUserInfo: ( roomId: number, callback: (data: { @@ -39,7 +45,6 @@ interface WebSocketStore { playlist: { order: number; url: string; title: string; youtuber: string }[]; }) => void, ) => void; - subscribeRooms: (roomIds: number[]) => void; unsubscribeAll: () => void; pubTopic: (destination: string, message: string) => void; } @@ -72,8 +77,49 @@ export const useWebSocketStore = create((set, get) => ({ const userId = useUserStore.getState().user?.userId; if (userId) { client.send('/app/connect', {}, JSON.stringify({ userId })); - get().subscribeInvitations(userId); - get().subscribeFriendConnection(userId); + get().subscribeInvitations(userId, message => { + console.log('subscribeInvitations', message); + useNotificationStore.getState().increaseNotificationCount(); + if (message.type === 'friend_request') { + useToastStore.getState().addToast( + `${message.senderNickname}님이 친구 요청을 보냈습니다.`, + 'info', + 5000, + [ + { + label: '수락', + onClick: () => { + useNotificationStore.getState().acceptFriend(message); + useToastStore.getState().removeToast(message.timestamp); + useNotificationStore.getState().decreaseNotificationCount(); + }, + }, + { + label: '거절', + onClick: () => { + useNotificationStore.getState().rejectFriend(message); + useToastStore.getState().removeToast(message.timestamp); + useNotificationStore.getState().decreaseNotificationCount(); + }, + }, + ], + message.timestamp, + ); + } else { + useToastStore + .getState() + .addToast( + `${message.senderNickname}님이 초대를 보냈습니다.`, + 'info', + 5000, + [], + message.timestamp, + ); + } + }); + get().subscribeFriendConnection(userId, message => { + useFriendStore.getState().updateFriendStatus(message.userId, message.status); + }); } }, error => { @@ -116,23 +162,19 @@ export const useWebSocketStore = create((set, get) => ({ }, // 친구 접속 알림 구독 - subscribeFriendConnection: (userId: number) => { + subscribeFriendConnection: (userId: number, callback: (message: T) => void = console.log) => { const destination = `/topic/user/${userId}/friend-state`; - get().subTopic(destination, message => { - console.log('subscribeFriendConnection', message); - }); + get().subTopic(destination, callback); }, // 초대 구독 - subscribeInvitations: (userId: number) => { + subscribeInvitations: (userId: number, callback: (message: T) => void = console.log) => { const destination = `/topic/user/${userId}/notification`; - get().subTopic(destination, message => { - console.log('subscribeInvitations', message); - }); + get().subTopic(destination, callback); }, // 방 채팅 구독 - subscribeRoomChat: (roomId: number) => { + subscribeRoom: (roomId: number) => { const destination = `/topic/room/${roomId}/chat`; get().subTopic(destination, message => { console.log('subscribeRoomChat', message); @@ -174,7 +216,7 @@ export const useWebSocketStore = create((set, get) => ({ subscribeRooms: (roomIds: number[]) => { if (!roomIds) return; roomIds.forEach(roomId => { - get().subscribeRoomChat(roomId); + get().subscribeRoom(roomId); }); }, diff --git a/src/frontend/src/types/dto/Friend.dto.ts b/src/frontend/src/types/dto/Friend.dto.ts new file mode 100644 index 00000000..2b21079f --- /dev/null +++ b/src/frontend/src/types/dto/Friend.dto.ts @@ -0,0 +1,5 @@ + +export interface FriendConnectionMessage { + status: string; + userId: number; +} diff --git a/src/frontend/src/types/enums/ButtonColor.ts b/src/frontend/src/types/enums/ButtonColor.ts index 05c9ebef..f556ce99 100644 --- a/src/frontend/src/types/enums/ButtonColor.ts +++ b/src/frontend/src/types/enums/ButtonColor.ts @@ -5,4 +5,5 @@ export enum ButtonColor { GRAY = '#d4d4d4', DARKGRAY = '#444444', TRANSPARENT = 'transparent', + BLACK = '#000000', } diff --git a/src/frontend/src/ui/Common.css.ts b/src/frontend/src/ui/Common.css.ts index 8134338c..997ee9b5 100644 --- a/src/frontend/src/ui/Common.css.ts +++ b/src/frontend/src/ui/Common.css.ts @@ -1,4 +1,4 @@ -import { styled } from 'styled-components'; +import { keyframes, styled } from 'styled-components'; export const Wrapper = styled.div` display: flex; @@ -57,9 +57,53 @@ export const CommonLi = styled.li<{ $hoverColor?: string }>` } `; +export const GreetingViewWrapper = styled(Wrapper)` + min-height: 80vh; + height: auto; +`; + +export const GreetingViewContainer = styled(Container)` + align-items: center; + justify-content: center; + gap: 2rem; +`; + +export const GreetingViewImage = styled.div` + max-width: 300px; + width: 100%; + margin-bottom: 1rem; +`; + +export const GreetingViewTitle = styled(Title)` + padding: 0; +`; + +export const GreetingViewSubTitle = styled(SubTitle)` + padding: 0; + color: var(--palette-font-gray-strong); + font-weight: 400; + font-size: 1.25rem; + margin-bottom: 1.5rem; +`; + export const Copyright = styled.p` font-size: 0.875rem; color: var(--palette-font-gray); text-align: center; margin-top: 2rem; `; + +const shimmer = keyframes` + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +`; + +export const SkeletonBase = styled.div` + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: ${shimmer} 1.5s infinite; +`; diff --git a/src/frontend/src/utils/errorUtils.ts b/src/frontend/src/utils/errorUtils.ts new file mode 100644 index 00000000..98ad4fa6 --- /dev/null +++ b/src/frontend/src/utils/errorUtils.ts @@ -0,0 +1,11 @@ +import axios from "axios"; + +export const getErrorMessage = (error: unknown): string => { + if (axios.isAxiosError(error) && error.response?.data) { + const message = error.response.data.message; + if (typeof message === "string") { + return message; + } + } + return "알 수 없는 오류가 발생했습니다."; +};