diff --git a/client/.eslintrc b/client/.eslintrc index e8578692..6048da0b 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -26,6 +26,10 @@ ], "@typescript-eslint/no-var-requires": "off", "react/no-unknown-property": "off", - "no-duplicate-imports": "off" + "no-duplicate-imports": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/consistent-type-imports": "error", + "no-nested-ternary": "off", + "@typescript-eslint/no-empty-interface": "off" } } diff --git a/client/.gitignore b/client/.gitignore index 8582caf0..b0c8c708 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,4 +1,5 @@ env/* build/* dist/* -.idea/ \ No newline at end of file +.idea/ +*.pem \ No newline at end of file diff --git a/client/config/webpack.dev.https.ts b/client/config/webpack.dev.https.ts new file mode 100644 index 00000000..13552d87 --- /dev/null +++ b/client/config/webpack.dev.https.ts @@ -0,0 +1,34 @@ +import 'webpack-dev-server'; + +import type { Configuration } from 'webpack'; + +import path from 'path'; + +import { merge } from 'webpack-merge'; + +import common from './webpack.common'; + +const config: Configuration = { + devtool: 'inline-source-map', + mode: 'development', + module: { + rules: [ + { + test: /\.css$/, + use: ['style-loader', 'css-loader', 'postcss-loader'], + exclude: /node_modules/, + }, + ], + }, + devServer: { + https: { + key: path.resolve(__dirname, '..', 'localhost+2-key.pem'), + cert: path.resolve(__dirname, '..', 'localhost+2.pem'), + }, + hot: true, + open: true, + historyApiFallback: true, + }, +}; + +export default merge(common, config); diff --git a/client/package.json b/client/package.json index 7c64c5ea..b22b0ec2 100644 --- a/client/package.json +++ b/client/package.json @@ -5,6 +5,7 @@ "license": "MIT", "scripts": { "dev": "cross-env NODE_ENV=development webpack serve --config ./config/webpack.dev.ts --progress", + "dev:https": "cross-env NODE_ENV=development webpack serve --config ./config/webpack.dev.https.ts --progress", "build": "cross-env NODE_ENV=production webpack --config ./config/webpack.prod.ts --progress", "analysis": "cross-env NODE_ENV=production webpack --config ./config/webpack.analysis.ts --progress", "test": "jest" @@ -15,8 +16,10 @@ "axios": "^1.1.3", "classnames": "^2.3.2", "react": "^18.2.0", + "react-custom-scrollbars-2": "^4.5.0", "react-dom": "^18.2.0", "react-hook-form": "^7.39.4", + "react-modal": "^3.16.1", "react-router-dom": "^6.4.3", "react-toastify": "^9.1.1", "shared": "1.0.0", @@ -29,6 +32,7 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", + "@faker-js/faker": "^7.6.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.9", "@tanstack/react-query-devtools": "^4.16.1", "@testing-library/jest-dom": "^5.16.5", @@ -36,6 +40,7 @@ "@types/jest": "^29.2.2", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.8", + "@types/react-modal": "^3.13.1", "@types/webpack-bundle-analyzer": "^4.6.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 072a03b9..7b823fb5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,5 @@ -import AuthorizedLayer from '@components/AuthorizedLayer'; -import UnAuthorizedLayer from '@components/UnAuthorizedLayer'; import AccessDenied from '@pages/AccessDenied'; +import AuthorizedLayer from '@pages/AuthorizedLayer'; import Community from '@pages/Community'; import DM from '@pages/DM'; import DMRoom from '@pages/DMRoom'; @@ -10,6 +9,8 @@ import NotFound from '@pages/NotFound'; import Root from '@pages/Root'; import SignIn from '@pages/SignIn'; import SignUp from '@pages/SignUp'; +import UnAuthorizedLayer from '@pages/UnAuthorizedLayer'; +import UnknownError from '@pages/UnknownError'; import React from 'react'; import { RouterProvider, @@ -38,7 +39,8 @@ const router = createBrowserRouter( } /> } /> - } /> + } /> + } /> } /> , ), diff --git a/client/src/apis/auth.ts b/client/src/apis/auth.ts new file mode 100644 index 00000000..7c9e17bc --- /dev/null +++ b/client/src/apis/auth.ts @@ -0,0 +1,66 @@ +import type { SuccessResponse } from '@@types/apis/response'; + +import { API_URL } from '@constants/url'; +import axios from 'axios'; + +export interface SignUpRequest { + id: string; + nickname: string; + password: string; +} + +export interface SignUpResult { + message: string; +} + +export type SignUp = ( + fields: SignUpRequest, +) => Promise>; + +export const signUp: SignUp = ({ id, nickname, password }) => { + const endPoint = `${API_URL}/api/user/auth/signup`; + + return axios + .post(endPoint, { id, nickname, password }) + .then((response) => response.data); +}; + +export interface SignInRequest { + id: string; + password: string; +} + +export interface SignInResult { + _id: string; + accessToken: string; +} + +export type SignIn = ( + fields: SignInRequest, +) => Promise>; + +export const signIn: SignIn = ({ id, password }) => { + const endPoint = `${API_URL}/api/user/auth/signin`; + + return axios + .post(endPoint, { id, password }, { withCredentials: true }) + .then((response) => response.data); +}; +// 액세스 토큰으로 다시 유저 정보 요청해야함 +// _id, id(이메일), nickname, status, profileUrl, description + +export interface ReissueTokenResult { + accessToken: string; +} + +export type ReissueToken = () => Promise>; + +export const reissueToken: ReissueToken = () => { + const endPoint = `${API_URL}/api/user/auth/refresh`; + + return axios + .post(endPoint, {}, { withCredentials: true }) + .then((response) => { + return response.data; + }); +}; diff --git a/client/src/apis/dm.ts b/client/src/apis/dm.ts new file mode 100644 index 00000000..a241d3c2 --- /dev/null +++ b/client/src/apis/dm.ts @@ -0,0 +1,16 @@ +import type { User } from '@apis/user'; + +import { API_URL } from '@constants/url'; +import axios from 'axios'; + +export interface DirectMessage { + _id: string; + user: User; +} + +export type GetDirectMessagesResult = DirectMessage[]; + +export type GetDirectMessages = () => Promise; + +export const getDirectMessages: GetDirectMessages = () => + axios.get(`${API_URL}/api/user/dms`).then((res) => res.data.result); diff --git a/client/src/apis/user.ts b/client/src/apis/user.ts new file mode 100644 index 00000000..a2e5f6d0 --- /dev/null +++ b/client/src/apis/user.ts @@ -0,0 +1,63 @@ +import type { SuccessResponse } from '@@types/apis/response'; + +import { API_URL } from '@constants/url'; +import axios from 'axios'; + +export type UserStatus = 'online' | 'offline' | 'afk'; + +export interface User { + _id: string; + id: string; + nickname: string; + status: UserStatus; + profileUrl: string; + description: string; +} + +export type MyInfoResult = User; + +type GetMyInfo = () => Promise; + +export const getMyInfo: GetMyInfo = () => { + return axios + .get(`${API_URL}/api/user/auth/me`) + .then((response) => response.data.result); +}; + +export interface GetFollowingsResult { + followings: User[]; +} +export type GetFollowingsResponse = SuccessResponse; + +export const getFollowings = (): Promise => + axios.get(`${API_URL}/api/user/followings`).then((res) => res.data); + +export interface UpdateFollowingResult { + message?: string; +} +export type UpdateFollowingResponse = SuccessResponse; + +export const updateFollowing = ( + userId: string, +): Promise => + axios.post(`${API_URL}/api/user/following/${userId}`).then((res) => res.data); + +export interface GetFollowersResult { + followers: User[]; +} + +export type GetFollowersResponse = SuccessResponse; + +export const getFollowers = (): Promise => + axios.get(`${API_URL}/api/user/followers`).then((res) => res.data); +export interface GetUsersParams { + search: string; +} +export interface GetUsersResult { + users: User[]; +} + +export type GetUsersResponse = SuccessResponse; + +export const GetUsers = (params: GetUsersParams): Promise => + axios.get(`${API_URL}/api/users`, { params }).then((res) => res.data); diff --git a/client/src/components/AuthInput/index.tsx b/client/src/components/AuthInput/index.tsx index 9161148e..d9258939 100644 --- a/client/src/components/AuthInput/index.tsx +++ b/client/src/components/AuthInput/index.tsx @@ -5,7 +5,7 @@ import type { } from 'react'; import cn from 'classnames'; -import React from 'react'; +import React, { forwardRef } from 'react'; interface Props extends ComponentPropsWithRef<'input'> { type?: HTMLInputTypeAttribute; @@ -15,49 +15,57 @@ interface Props extends ComponentPropsWithRef<'input'> { placeholder?: string; } -const AuthInput: React.FC = ({ - type = 'text', - value = '', - onChange, - className = '', - placeholder = 'Default', - ...restProps -}) => { - const inputValueFilled = value.length >= 1; - - const movePlaceholderTop = cn([ +const AuthInput: React.FC = forwardRef( + ( { - 'top-1/2': !inputValueFilled, - 'text-s16': !inputValueFilled, - 'top-[16px]': inputValueFilled, - 'text-s12': inputValueFilled, + type = 'text', + value = '', + onChange, + className = '', + placeholder = 'Default', + ...restProps }, - ]); + ref, + ) => { + const inputValueFilled = value.length >= 1; - const moveInputValueBottom = cn([ - { - 'pt-6': inputValueFilled, - }, - ]); - - return ( -
- + const movePlaceholderTop = cn([ + { + 'top-1/2': !inputValueFilled, + 'text-s16': !inputValueFilled, + 'top-[16px]': inputValueFilled, + 'text-s12': inputValueFilled, + }, + ]); + + const moveInputValueBottom = cn([ + { + 'pt-6': inputValueFilled, + }, + ]); + + return (
- {placeholder} + +
+ {placeholder} +
-
- ); -}; + ); + }, +); + +AuthInput.displayName = 'AuthInput'; export default AuthInput; diff --git a/client/src/components/AuthorizedLayer/index.tsx b/client/src/components/AuthorizedLayer/index.tsx deleted file mode 100644 index f1d0d205..00000000 --- a/client/src/components/AuthorizedLayer/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; - -const AuthorizedLayer = () => { - return ; -}; - -export default AuthorizedLayer; diff --git a/client/src/components/Avatar/index.tsx b/client/src/components/Avatar/index.tsx index 88f4c14e..c339739d 100644 --- a/client/src/components/Avatar/index.tsx +++ b/client/src/components/Avatar/index.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import React from 'react'; export interface AvatarProps { @@ -5,6 +7,8 @@ export interface AvatarProps { variant: 'circle' | 'rectangle'; name: string; url?: string; + className?: string; + children?: ReactNode; } const ROUNDED = { @@ -12,25 +16,47 @@ const ROUNDED = { circle: 'rounded-full', }; -const WH = { +const SCALE = { small: 'w-[57px] h-[57px]', medium: 'w-[65px] h-[65px]', }; -const Avatar: React.FC = ({ name, url, size, variant }) => { +const getFirstLetter = (str: string) => { + const firstLetter = str.at(0); + + if (!firstLetter) { + console.warn( + `getFirstLetter의 인자로는 반드시 길이 1이상의 문자열이 들어와야 합니다.`, + ); + return ''; + } + + return firstLetter; +}; + +const Avatar: React.FC = ({ + name, + url, + size, + variant, + className = '', + children, +}) => { return (
- {url ? ( - {`${name}의 - ) : ( - name.at(0) - )} + {children} + {!children && + (url ? ( + {`${name}의 + ) : ( + getFirstLetter(name) + ))}
); }; diff --git a/client/src/components/Button/index.tsx b/client/src/components/Button/index.tsx index 115a0a4a..2c00cd1c 100644 --- a/client/src/components/Button/index.tsx +++ b/client/src/components/Button/index.tsx @@ -15,13 +15,13 @@ const buttonRounded = { const buttonBg = (outlined: boolean) => ({ primary: outlined ? 'border-primary hover:border-primary-dark active:border-primary' - : 'bg-primary hover:bg-primary-dark active:bg-primary', + : 'bg-primary hover:bg-primary-dark active:bg-primary border-primary', secondary: outlined ? 'border-secondary hover:border-secondary-dark active:border-secondary' - : 'bg-secondary hover:bg-secondary-dark active:bg-secondary', + : 'bg-secondary hover:bg-secondary-dark active:bg-secondary border-secondary', dark: outlined ? 'border-indigo hover:border-titleActive active:border-indigo' - : 'bg-indigo hover:bg-titleActive active:bg-indigo', + : 'bg-indigo hover:bg-titleActive active:bg-indigo border-indigo', }); const buttonText = (outlined: boolean) => ({ diff --git a/client/src/components/CommunityAvatar/index.tsx b/client/src/components/CommunityAvatar/index.tsx deleted file mode 100644 index bc0306f7..00000000 --- a/client/src/components/CommunityAvatar/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Avatar, { AvatarProps } from '@components/Avatar'; -import React from 'react'; - -const CommunityAvatar: React.FC = ({ - variant = 'rectangle', - size = 'medium', - name, - url, -}) => { - return ; -}; - -export default CommunityAvatar; diff --git a/client/src/components/FollowerUserItem/index.tsx b/client/src/components/FollowerUserItem/index.tsx new file mode 100644 index 00000000..7eefe8fd --- /dev/null +++ b/client/src/components/FollowerUserItem/index.tsx @@ -0,0 +1,27 @@ +import type { User } from '@apis/user'; + +import UserItem from '@components/UserItem'; +import { EllipsisHorizontalIcon } from '@heroicons/react/20/solid'; +import React from 'react'; + +interface Props { + user: User; +} + +const FollowerUserItem: React.FC = ({ user }) => { + return ( + + + + } + /> + ); +}; + +export default FollowerUserItem; diff --git a/client/src/components/FollowingUserItem/index.tsx b/client/src/components/FollowingUserItem/index.tsx new file mode 100644 index 00000000..038f8459 --- /dev/null +++ b/client/src/components/FollowingUserItem/index.tsx @@ -0,0 +1,45 @@ +import type { User } from '@apis/user'; + +import UserItem from '@components/UserItem'; +import { + EllipsisHorizontalIcon, + ChatBubbleLeftIcon, +} from '@heroicons/react/20/solid'; +import useFollowingMutation from '@hooks/useFollowingMutation'; +import React, { memo } from 'react'; +import { Link } from 'react-router-dom'; + +interface Props { + user: User; +} + +const FollowingUserItem: React.FC = ({ user }) => { + const followingMutation = useFollowingMutation(user._id); + const { mutate: updateFollowing } = followingMutation; + + return ( + + + 다이렉트 메시지 + + + + + } + /> + ); +}; + +export default memo(FollowingUserItem); diff --git a/client/src/components/GnbItemContainer/index.tsx b/client/src/components/GnbItemContainer/index.tsx new file mode 100644 index 00000000..2f49c3c0 --- /dev/null +++ b/client/src/components/GnbItemContainer/index.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; + +import cn from 'classnames'; +import React, { useState, memo, useCallback } from 'react'; + +interface Props { + children: ReactNode; + disableLeftFillBar?: boolean; + isActive?: boolean; + tooltip?: string; +} + +// TODO: Tooltip 추가하기 +const GnbItemContainer: React.FC = ({ + children, + disableLeftFillBar = false, + isActive = false, +}) => { + const [isItemHover, setIsItemHover] = useState(false); + const leftFillBarClassnames = disableLeftFillBar + ? '' + : cn({ + 'bg-primary-light': isItemHover, + 'bg-primary-dark': isActive, + }); + + const handleMouseEnterOnItem = useCallback(() => setIsItemHover(true), []); + const handleMouseLeaveFromItem = useCallback(() => setIsItemHover(false), []); + + return ( +
+
+
+ {/* Item 영역 */} +
+ {children} +
+
+
+ ); +}; + +export default memo(GnbItemContainer); diff --git a/client/src/components/Modals/CreateCommunityModal/index.tsx b/client/src/components/Modals/CreateCommunityModal/index.tsx new file mode 100644 index 00000000..75daa3d7 --- /dev/null +++ b/client/src/components/Modals/CreateCommunityModal/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactModal from 'react-modal'; + +interface Props {} + +const CreateCommunityModal: React.FC = () => { + return 커뮤니티 모달; +}; + +export default CreateCommunityModal; diff --git a/client/src/layouts/FollowingTab/components/searchInput.tsx b/client/src/components/SearchInput/index.tsx similarity index 57% rename from client/src/layouts/FollowingTab/components/searchInput.tsx rename to client/src/components/SearchInput/index.tsx index 8865bdb6..ad655d8e 100644 --- a/client/src/layouts/FollowingTab/components/searchInput.tsx +++ b/client/src/components/SearchInput/index.tsx @@ -1,22 +1,28 @@ +import type { InputHTMLAttributes } from 'react'; + import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'; import React from 'react'; -type SearchInputProps = React.InputHTMLAttributes; +type SearchInputProps = InputHTMLAttributes; const SearchInput: React.FC = ({ + className, value, onChange, placeholder, + ...props }) => { return ( -
+
+ 검색
= ({ ); }; -SearchInput.displayName = 'SearchInput'; - export default SearchInput; diff --git a/client/src/components/UnAuthorizedLayer/index.tsx b/client/src/components/UnAuthorizedLayer/index.tsx deleted file mode 100644 index 3d9c16f2..00000000 --- a/client/src/components/UnAuthorizedLayer/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; - -const UnAuthorizedLayer = () => { - return ; -}; - -export default UnAuthorizedLayer; diff --git a/client/src/components/UserAvatar/index.tsx b/client/src/components/UserAvatar/index.tsx deleted file mode 100644 index 2f7dbaa4..00000000 --- a/client/src/components/UserAvatar/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Avatar, { AvatarProps } from '@components/Avatar'; -import React from 'react'; - -const UserAvatar: React.FC = ({ - variant = 'circle', - size = 'small', - name, - url, -}) => { - return ; -}; - -export default UserAvatar; diff --git a/client/src/components/UserItem/index.tsx b/client/src/components/UserItem/index.tsx new file mode 100644 index 00000000..6d4d1d78 --- /dev/null +++ b/client/src/components/UserItem/index.tsx @@ -0,0 +1,21 @@ +import type { User } from '@apis/user'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +import UserProfile from '@components/UserProfile'; +import React, { memo } from 'react'; + +interface Props extends ComponentPropsWithoutRef<'li'> { + user: User; + right?: ReactNode; +} + +const UserItem: React.FC = ({ user, right }) => { + return ( +
  • + + {right} +
  • + ); +}; + +export default memo(UserItem); diff --git a/client/src/components/UserList/index.tsx b/client/src/components/UserList/index.tsx new file mode 100644 index 00000000..e9fa6eb7 --- /dev/null +++ b/client/src/components/UserList/index.tsx @@ -0,0 +1,17 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +import React from 'react'; + +interface Props extends ComponentPropsWithoutRef<'ul'> { + children?: ReactNode; +} + +const UserList = ({ children }: Props) => { + return ( +
      + {children} +
    + ); +}; + +export default UserList; diff --git a/client/src/components/UserProfile/index.tsx b/client/src/components/UserProfile/index.tsx index f86de791..6c0c1446 100644 --- a/client/src/components/UserProfile/index.tsx +++ b/client/src/components/UserProfile/index.tsx @@ -1,34 +1,27 @@ +import type { User } from '@apis/user'; +import type { ComponentPropsWithoutRef } from 'react'; + import Avatar from '@components/Avatar'; import Badge from '@components/Badge'; +import { USER_STATUS } from '@constants/user'; import React from 'react'; -import { User } from 'shared/lib/user'; -interface UserItemProps { +interface Props extends ComponentPropsWithoutRef<'div'> { user: User; } -const USER_STATUS = { - OFFLINE: 'offline', - ONLINE: 'online', - AFK: 'afk', -}; - -type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS]; - -const statusColor: { - [key: UserStatus]: 'default' | 'success' | 'error'; -} = { +const STATUS_COLOR = { [USER_STATUS.OFFLINE]: 'default', [USER_STATUS.ONLINE]: 'success', [USER_STATUS.AFK]: 'error', -}; +} as const; -const UserProfile: React.FC = ({ +const UserProfile: React.FC = ({ user: { nickname, profileUrl, status }, }) => { return ( -
    - +
    + { + if (error instanceof AxiosError) { + const errorMessage = + error?.response?.data?.message || '에러가 발생했습니다!'; + + if (Array.isArray(errorMessage)) { + errorMessage.forEach((message) => { + toast.error(message); + }); + return; + } + + toast.error(errorMessage); + return; + } + + toast.error('Unknown Error'); +}; + +export default defaultErrorHandler; diff --git a/client/src/layouts/FollowingTab/hooks/useDebouncedValue.ts b/client/src/hooks/useDebouncedValue.ts similarity index 100% rename from client/src/layouts/FollowingTab/hooks/useDebouncedValue.ts rename to client/src/hooks/useDebouncedValue.ts diff --git a/client/src/hooks/useDirectMessagesQuery.ts b/client/src/hooks/useDirectMessagesQuery.ts new file mode 100644 index 00000000..474f4ac5 --- /dev/null +++ b/client/src/hooks/useDirectMessagesQuery.ts @@ -0,0 +1,18 @@ +import type { GetDirectMessagesResult } from '@apis/dm'; +import type { AxiosError } from 'axios'; + +import { getDirectMessages } from '@apis/dm'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useDirectMessagesQuery = () => { + const query = useQuery( + queryKeyCreator.directMessage.list(), + getDirectMessages, + ); + + return query; +}; + +export default useDirectMessagesQuery; diff --git a/client/src/hooks/useFollowersQuery.ts b/client/src/hooks/useFollowersQuery.ts new file mode 100644 index 00000000..a3ff8668 --- /dev/null +++ b/client/src/hooks/useFollowersQuery.ts @@ -0,0 +1,36 @@ +import type { GetFollowersResponse, GetFollowersResult } from '@apis/user'; + +import { getFollowers } from '@apis/user'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type FollowersQueryData = { + statusCode: number; +} & GetFollowersResult; + +const useFollowersQuery = (filter: string, options?: { suspense: boolean }) => { + const key = queryKeyCreator.followers(); + const query = useQuery< + GetFollowersResponse, + unknown, + FollowersQueryData, + [string] + >(key, getFollowers, { + ...options, + select: (data) => { + const { statusCode, result } = data; + const followers = filter + ? result.followers.filter(({ nickname }) => + nickname.toUpperCase().includes(filter.toUpperCase()), + ) + : result.followers; + + return { statusCode, ...result, followers }; + }, + }); + + return query; +}; + +export default useFollowersQuery; diff --git a/client/src/hooks/useFollowingMutation.ts b/client/src/hooks/useFollowingMutation.ts new file mode 100644 index 00000000..72f5cacb --- /dev/null +++ b/client/src/hooks/useFollowingMutation.ts @@ -0,0 +1,46 @@ +import type { GetFollowingsResponse, User } from '@apis/user'; + +import { updateFollowing } from '@apis/user'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useFollowingMutation = (userId: string) => { + const key = queryKeyCreator.followings(); + const queryClient = useQueryClient(); + const mutation = useMutation(() => updateFollowing(userId), { + onMutate: async (deleted: User) => { + await queryClient.cancelQueries(key); + + const previousFollowings = + queryClient.getQueryData(key); + + if (previousFollowings) { + queryClient.setQueryData(key, { + ...previousFollowings, + result: { + ...previousFollowings.result, + followings: previousFollowings.result.followings.filter( + (following) => following._id !== deleted._id, + ), + }, + }); + } + return { previousFollowings }; + }, + onError: (err, variables, context) => { + if (context?.previousFollowings) + queryClient.setQueryData( + key, + context.previousFollowings, + ); + }, + onSettled: () => { + queryClient.invalidateQueries(key); + }, + }); + + return mutation; +}; + +export default useFollowingMutation; diff --git a/client/src/hooks/useFollowingsQuery.ts b/client/src/hooks/useFollowingsQuery.ts new file mode 100644 index 00000000..1d6cd0af --- /dev/null +++ b/client/src/hooks/useFollowingsQuery.ts @@ -0,0 +1,39 @@ +import type { GetFollowingsResponse, GetFollowingsResult } from '@apis/user'; + +import { getFollowings } from '@apis/user'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type FollowingsQueryData = { + statusCode: number; +} & GetFollowingsResult; + +const useFollowingsQuery = ( + filter: string, + options?: { suspense: boolean }, +) => { + const key = queryKeyCreator.followings(); + const query = useQuery< + GetFollowingsResponse, + unknown, + FollowingsQueryData, + [string] + >(key, getFollowings, { + ...options, + select: (data) => { + const { statusCode, result } = data; + const followings = filter + ? result.followings.filter(({ nickname }) => + nickname.toUpperCase().includes(filter.toUpperCase()), + ) + : result.followings; + + return { statusCode, ...result, followings }; + }, + }); + + return query; +}; + +export default useFollowingsQuery; diff --git a/client/src/hooks/useMyInfoQuery.ts b/client/src/hooks/useMyInfoQuery.ts new file mode 100644 index 00000000..f8c6d976 --- /dev/null +++ b/client/src/hooks/useMyInfoQuery.ts @@ -0,0 +1,23 @@ +import type { MyInfoResult } from '@apis/user'; +import type { AxiosError } from 'axios'; + +import { getMyInfo } from '@apis/user'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import queryKeyCreator from 'src/queryKeyCreator'; + +export const useMyInfoQuery = () => { + const key = queryKeyCreator.me(); + const query = useQuery(key, getMyInfo, {}); + + return query; +}; + +export default useMyInfoQuery; + +export const useMyInfo = () => { + const queryClient = useQueryClient(); + const key = queryKeyCreator.me(); + const me = queryClient.getQueryData(key); + + return me; +}; diff --git a/client/src/hooks/useReissueTokenMutation.ts b/client/src/hooks/useReissueTokenMutation.ts new file mode 100644 index 00000000..d30af9c3 --- /dev/null +++ b/client/src/hooks/useReissueTokenMutation.ts @@ -0,0 +1,69 @@ +import type { ErrorResponse, SuccessResponse } from '@@types/apis/response'; +import type { ReissueTokenResult } from '@apis/auth'; +import type { UseMutationResult } from '@tanstack/react-query'; + +import { reissueToken } from '@apis/auth'; +import { useTokenStore } from '@stores/tokenStore'; +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { useNavigate } from 'react-router-dom'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type UseReissueTokenMutationResult = UseMutationResult< + SuccessResponse, + unknown, + void, + unknown +>; + +interface UseReissueTokenMutation { + ( + invalidTokenErrorFallback?: string | (() => void), + unknownErrorFallback?: string | (() => void), + ): UseReissueTokenMutationResult; +} + +/** + * @description 서버에서 401 에러가 발생하는 경우에만 accessToken을 리셋하기 때문에, 그 이외의 에러로 로그인이 풀리지는 않음. + */ +const useReissueTokenMutation: UseReissueTokenMutation = ( + invalidTokenErrorFallback, + unknownErrorFallback, +) => { + const navigate = useNavigate(); + const setAccessToken = useTokenStore((state) => state.setAccessToken); + const key = queryKeyCreator.reissueToken(); + const mutation = useMutation(key, reissueToken, { + onSuccess: (data) => { + setAccessToken(data.result.accessToken); + }, + onError: (error) => { + if (!(error instanceof AxiosError)) { + console.error(error); + return; + } + + const errorResponse = error.response?.data as ErrorResponse | undefined; + + /** 유효하지 않은 토큰 */ + if (errorResponse?.statusCode === 401) { + setAccessToken(null); + if (typeof invalidTokenErrorFallback === 'string') + navigate(invalidTokenErrorFallback); + else invalidTokenErrorFallback && invalidTokenErrorFallback(); + + return; + } + + /** 네트워크 오류나 기타 서버 오류 등 */ + if (typeof unknownErrorFallback === 'string') { + navigate(unknownErrorFallback); + } else unknownErrorFallback && unknownErrorFallback(); + }, + }); + + return mutation; +}; + +export default useReissueTokenMutation; diff --git a/client/src/hooks/useSignInMutation.ts b/client/src/hooks/useSignInMutation.ts new file mode 100644 index 00000000..c8b118f4 --- /dev/null +++ b/client/src/hooks/useSignInMutation.ts @@ -0,0 +1,25 @@ +import type { SuccessResponse } from '@@types/apis/response'; +import type { SignInRequest, SignInResult } from '@apis/auth'; +import type { UseMutationOptions } from '@tanstack/react-query'; + +import { signIn } from '@apis/auth'; +import { useMutation } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useSignInMutation = ( + options: UseMutationOptions< + SuccessResponse, + unknown, + SignInRequest + >, +) => { + const key = queryKeyCreator.signIn(); + const mutation = useMutation(key, signIn, { + ...options, + }); + + return mutation; +}; + +export default useSignInMutation; diff --git a/client/src/hooks/useSignUpMutation.ts b/client/src/hooks/useSignUpMutation.ts new file mode 100644 index 00000000..eceb2fef --- /dev/null +++ b/client/src/hooks/useSignUpMutation.ts @@ -0,0 +1,25 @@ +import type { SuccessResponse } from '@@types/apis/response'; +import type { SignUpRequest, SignUpResult } from '@apis/auth'; +import type { UseMutationOptions } from '@tanstack/react-query'; + +import { signUp } from '@apis/auth'; +import { useMutation } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useSignUpMutation = ( + options: UseMutationOptions< + SuccessResponse, + unknown, + SignUpRequest + >, +) => { + const key = queryKeyCreator.signUp(); + const mutation = useMutation(key, signUp, { + ...options, + }); + + return mutation; +}; + +export default useSignUpMutation; diff --git a/client/src/hooks/useUsersQuery.ts b/client/src/hooks/useUsersQuery.ts new file mode 100644 index 00000000..7f375c8b --- /dev/null +++ b/client/src/hooks/useUsersQuery.ts @@ -0,0 +1,34 @@ +import type { GetUsersResponse, GetUsersResult } from '@apis/user'; + +import { GetUsers } from '@apis/user'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type UsersQueryData = { + statusCode: number; +} & GetUsersResult; + +const useUserSearchQuery = ( + filter: string, + options?: { suspense?: boolean; enabled?: boolean }, +) => { + const key = queryKeyCreator.userSearch(filter); + const query = useQuery( + key, + () => GetUsers({ search: filter }), + { + ...options, + select: (data) => { + const { statusCode, result } = data; + const { users } = result; + + return { statusCode, ...result, users }; + }, + }, + ); + + return query; +}; + +export default useUserSearchQuery; diff --git a/client/src/index.css b/client/src/index.css index ff8c90ab..5510c518 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -10,4 +10,21 @@ #root { width: 100vw; -} \ No newline at end of file +} + +.wrapper { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.no-display-scrollbar::-webkit-scrollbar { + display: none; +} + +.no-display-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/client/src/index.tsx b/client/src/index.tsx index c7d541f2..ac339abd 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -19,10 +19,8 @@ const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); -/** - * react-toastify import css 안되는 이슈 - * // TODO: 링크 업데이트하기 - */ +// https://github.com/fkhadra/react-toastify/issues/195#issuecomment-860722903 +// https://grand-beanie-e57.notion.site/react-toastify-import-css-2b14956185394af797bc1c1842207473 injectStyle(); root.render( diff --git a/client/src/layouts/CommunityNav/index.tsx b/client/src/layouts/CommunityNav/index.tsx index 7145d6d3..2a66c10d 100644 --- a/client/src/layouts/CommunityNav/index.tsx +++ b/client/src/layouts/CommunityNav/index.tsx @@ -5,11 +5,11 @@ const CommunityNav = () => { const { communityId } = useParams(); return ( - <> + ); }; diff --git a/client/src/layouts/DmNav/index.tsx b/client/src/layouts/DmNav/index.tsx index e56fd6cb..281e2d2b 100644 --- a/client/src/layouts/DmNav/index.tsx +++ b/client/src/layouts/DmNav/index.tsx @@ -1,13 +1,32 @@ +import UserProfile from '@components/UserProfile'; +import useDirectMessagesQuery from '@hooks/useDirectMessagesQuery'; import React from 'react'; import { Link } from 'react-router-dom'; const DmNav = () => { + const directMessagesQuery = useDirectMessagesQuery(); + return ( - <> + ); }; diff --git a/client/src/layouts/Followers/index.tsx b/client/src/layouts/Followers/index.tsx new file mode 100644 index 00000000..d00dd559 --- /dev/null +++ b/client/src/layouts/Followers/index.tsx @@ -0,0 +1,41 @@ +import FollowerUserItem from '@components/FollowerUserItem'; +import SearchInput from '@components/SearchInput'; +import UserList from '@components/UserList'; +import useDebouncedValue from '@hooks/useDebouncedValue'; +import useFollowersQuery from '@hooks/useFollowersQuery'; +import React, { useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +const Followers = () => { + const DEBOUNCE_DELAY = 500; + const [filter, setFilter] = useState(''); + const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_DELAY); + const followersQuery = useFollowersQuery(debouncedFilter); + + return ( +
    +
    + setFilter(e.target.value)} + placeholder="검색하기" + /> +
    + + {followersQuery.isLoading ? ( +
    로딩중...
    + ) : followersQuery.data?.followers.length ? ( + + {followersQuery.data.followers.map((user) => ( + + ))} + + ) : ( + '일치하는 사용자가 없습니다.' + )} +
    +
    + ); +}; + +export default Followers; diff --git a/client/src/layouts/FollowingTab/apis/getFollowings.ts b/client/src/layouts/FollowingTab/apis/getFollowings.ts deleted file mode 100644 index 2955f518..00000000 --- a/client/src/layouts/FollowingTab/apis/getFollowings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { API_URL } from '@constants/url'; -import axios from 'axios'; - -const BASE_URL = `${API_URL}/api`; - -const getFollowings = (query: string) => - axios - .get(`${BASE_URL}/user/followings?query=${query}`) - .then((res) => res.data); - -export default getFollowings; diff --git a/client/src/layouts/FollowingTab/apis/updateFollowing.ts b/client/src/layouts/FollowingTab/apis/updateFollowing.ts deleted file mode 100644 index 8b581487..00000000 --- a/client/src/layouts/FollowingTab/apis/updateFollowing.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { API_URL } from '@constants/url'; -import axios from 'axios'; - -const BASE_URL = `${API_URL}/api`; - -const updateFollowing = (userId: string) => - axios.post(`${BASE_URL}/user/following/${userId}`).then((res) => res.data); - -export default updateFollowing; diff --git a/client/src/layouts/FollowingTab/components/item.tsx b/client/src/layouts/FollowingTab/components/item.tsx deleted file mode 100644 index dfdc5cd2..00000000 --- a/client/src/layouts/FollowingTab/components/item.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import UserProfile from '@components/UserProfile'; -import { - EllipsisHorizontalIcon, - ChatBubbleLeftIcon, -} from '@heroicons/react/20/solid'; -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { User } from 'shared/lib/user'; - -import useFollowingsMutation from '../hooks/useFollowingsMutation'; - -interface FollowingProps { - user: User; -} - -const FollowingItem: React.FC = ({ user }) => { - const navigate = useNavigate(); - const updateFollowing = useFollowingsMutation(user._id); - - const handleChatButtonClick = () => { - navigate(`/dms/${user._id}`); - }; - - return ( -
  • - -
    - - -
    -
  • - ); -}; - -export default FollowingItem; diff --git a/client/src/layouts/FollowingTab/components/list.tsx b/client/src/layouts/FollowingTab/components/list.tsx deleted file mode 100644 index 9d024caf..00000000 --- a/client/src/layouts/FollowingTab/components/list.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { User } from 'shared/lib/user'; - -import FollowingItem from './item'; - -interface FollowingListProps { - users: User[]; -} - -const FollowingList: React.FC = ({ users }) => { - return ( -
      - {users.map((user: User) => ( - - ))} -
    - ); -}; - -export default FollowingList; diff --git a/client/src/layouts/FollowingTab/hooks/useFollowingsMutation.ts b/client/src/layouts/FollowingTab/hooks/useFollowingsMutation.ts deleted file mode 100644 index 88fec020..00000000 --- a/client/src/layouts/FollowingTab/hooks/useFollowingsMutation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { GetUsersReponse, User } from 'shared/lib/user'; - -import updateFollowing from '../apis/updateFollowing'; - -const useFollowingsMutation = (userId: string) => { - const queryClient = useQueryClient(); - const mutation = useMutation(() => updateFollowing(userId), { - onMutate: async (deleted: User) => { - await queryClient.cancelQueries(['followings']); - const previousFollowings = queryClient.getQueryData([ - 'followings', - ]); - - if (previousFollowings) { - const { users } = previousFollowings.result; - - queryClient.setQueryData(['followings'], { - ...previousFollowings, - result: { - ...previousFollowings.result, - users: users.filter((user) => user._id !== deleted._id), - }, - }); - } - return { previousFollowings }; - }, - onError: (err, variables, context) => { - if (context?.previousFollowings) - queryClient.setQueryData( - ['followings'], - context.previousFollowings, - ); - }, - onSettled: () => { - queryClient.invalidateQueries(['followings']); - }, - }); - - return mutation; -}; - -export default useFollowingsMutation; diff --git a/client/src/layouts/FollowingTab/hooks/useFollowingsQuery.ts b/client/src/layouts/FollowingTab/hooks/useFollowingsQuery.ts deleted file mode 100644 index 9cb25d2d..00000000 --- a/client/src/layouts/FollowingTab/hooks/useFollowingsQuery.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import getFollowings from '../apis/getFollowings'; - -const useFollowingsQuery = (search: string, options?: Record) => { - const query = useQuery( - ['followings', search], - () => getFollowings(search), - options, - ); - - return query; -}; - -export default useFollowingsQuery; diff --git a/client/src/layouts/FollowingTab/index.tsx b/client/src/layouts/FollowingTab/index.tsx deleted file mode 100644 index 8660f4d3..00000000 --- a/client/src/layouts/FollowingTab/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useState, Suspense } from 'react'; - -import FollowingList from './components/list'; -import SearchInput from './components/searchInput'; -import useDebouncedValue from './hooks/useDebouncedValue'; -import useFollowingsQuery from './hooks/useFollowingsQuery'; - -const FollowingTab = () => { - const DEBOUNCE_DELAY = 500; - const [filter, setFilter] = useState(''); - const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_DELAY); - const { data } = useFollowingsQuery(debouncedFilter, { suspense: true }); - - return ( -
    -
    - setFilter(e.target.value)} - placeholder="검색하기" - /> -
    - loading...
    }> - {data.result.followings.length ? ( - - ) : ( - '일치하는 사용자가 없습니다.' - )} - -
    - ); -}; - -export default FollowingTab; diff --git a/client/src/layouts/Followings/index.tsx b/client/src/layouts/Followings/index.tsx new file mode 100644 index 00000000..645f0f94 --- /dev/null +++ b/client/src/layouts/Followings/index.tsx @@ -0,0 +1,41 @@ +import FollowingUserItem from '@components/FollowingUserItem'; +import SearchInput from '@components/SearchInput'; +import UserList from '@components/UserList'; +import useDebouncedValue from '@hooks/useDebouncedValue'; +import useFollowingsQuery from '@hooks/useFollowingsQuery'; +import React, { useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +const Followings = () => { + const DEBOUNCE_DELAY = 500; + const [filter, setFilter] = useState(''); + const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_DELAY); + const followingsQuery = useFollowingsQuery(debouncedFilter); + + return ( +
    +
    + setFilter(e.target.value)} + placeholder="검색하기" + /> +
    + + {followingsQuery.isLoading ? ( +
    로딩중...
    + ) : followingsQuery.data?.followings.length ? ( + + {followingsQuery.data.followings.map((user) => ( + + ))} + + ) : ( + '일치하는 사용자가 없습니다.' + )} +
    +
    + ); +}; + +export default Followings; diff --git a/client/src/layouts/Gnb/index.tsx b/client/src/layouts/Gnb/index.tsx index 49f5e6b8..847b8700 100644 --- a/client/src/layouts/Gnb/index.tsx +++ b/client/src/layouts/Gnb/index.tsx @@ -1,9 +1,53 @@ +import Avatar from '@components/Avatar'; +import GnbItemContainer from '@components/GnbItemContainer'; +import { LOGO_IMG_URL } from '@constants/url'; +import { PlusIcon } from '@heroicons/react/24/solid'; import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; const Gnb = () => { + const { pathname } = useLocation(); + return ( -
    - Gnb +
    +
    + + + + + + +
    + +
      + + + + + + + + + + +
    + + +
    ); }; diff --git a/client/src/layouts/Sidebar/index.tsx b/client/src/layouts/Sidebar/index.tsx index 1d436ad9..ccf25476 100644 --- a/client/src/layouts/Sidebar/index.tsx +++ b/client/src/layouts/Sidebar/index.tsx @@ -1,26 +1,28 @@ import UserProfile from '@components/UserProfile'; -import CommunityNav from '@features/CommunityNav'; -import DmNav from '@features/DmNav'; import { Cog6ToothIcon } from '@heroicons/react/20/solid'; -import { useQuery } from '@tanstack/react-query'; -import axios from 'axios'; +import useMyInfoQuery from '@hooks/useMyInfoQuery'; +import CommunityNav from '@layouts/CommunityNav'; +import DmNav from '@layouts/DmNav'; import React from 'react'; import { useLocation } from 'react-router-dom'; -const getMyInfo = () => axios.get('/api/user/auth/me').then((res) => res.data); - const Sidebar = () => { const { pathname } = useLocation(); - const { isLoading, data } = useQuery(['me'], getMyInfo); + const myInfoQuery = useMyInfoQuery(); return (
    - +
    - {isLoading ? 'loading' : } + {myInfoQuery.isLoading ? ( +
    로딩중...
    + ) : ( + myInfoQuery.data && + )}
    diff --git a/client/src/layouts/UserSearch/index.tsx b/client/src/layouts/UserSearch/index.tsx new file mode 100644 index 00000000..e94d20dc --- /dev/null +++ b/client/src/layouts/UserSearch/index.tsx @@ -0,0 +1,60 @@ +import type { FormEvent } from 'react'; + +import FollowerUserItem from '@components/FollowerUserItem'; +import SearchInput from '@components/SearchInput'; +import UserList from '@components/UserList'; +import useUsersQuery from '@hooks/useUsersQuery'; +import React, { useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +import Button from '@/components/Button'; + +// TODO: `handleKeyDown` 이벤트 핸들러 네이밍 명확하게 지어야함 +const UserSearch = () => { + const [submittedFilter, setSubmittedFilter] = useState(''); + const usersQuery = useUsersQuery(submittedFilter, { + enabled: !!submittedFilter, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const filter = + (new FormData(e.currentTarget).get('user-search') as string) ?? ''; + + if (filter.length === 0) return; + + setSubmittedFilter(filter); + }; + + return ( +
    +
    +
    + + + +
    + + {usersQuery.data?.users.length ? ( + + {usersQuery.data.users.map((user) => ( + + ))} + + ) : ( +
    + 검색된 사용자가 없습니다 +
    + )} +
    +
    + ); +}; + +export default UserSearch; diff --git a/client/src/mocks/data/users.js b/client/src/mocks/data/users.js index a2d890e9..1990c30d 100644 --- a/client/src/mocks/data/users.js +++ b/client/src/mocks/data/users.js @@ -1,36 +1,14 @@ +import { faker } from '@faker-js/faker'; + import { getRandomInt } from '../utils/rand'; -export const users = [ - { - _id: 'a', - id: '1', - nickname: '나영', - status: 'online', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default description', - }, - { - _id: 'b', - id: '2', - nickname: '수만', - status: 'offline', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default descrption', - }, - { - _id: 'c', - id: '3', - nickname: '민종', - status: 'afk', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default descrption', - }, - { - _id: 'd', - id: '4', - nickname: '준영', - status: 'afk', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default descrption', - }, -]; +export const createMockUser = () => ({ + _id: faker.datatype.uuid(), + id: faker.internet.email(), + nickname: faker.name.fullName(), + status: ['online', 'offline', 'afk'][getRandomInt(3)], + profileUrl: faker.image.avatar(), + description: faker.lorem.sentence(), +}); + +export const users = [...Array(30)].map(createMockUser); diff --git a/client/src/mocks/handlers/Auth.js b/client/src/mocks/handlers/Auth.js index 074d78d5..b06b703b 100644 --- a/client/src/mocks/handlers/Auth.js +++ b/client/src/mocks/handlers/Auth.js @@ -4,6 +4,9 @@ import { rest } from 'msw'; import { users } from '../data/users'; const BASE_URL = `${API_URL}/api`; +const devCookies = { + refreshTokenKey: 'dev_refreshToken', +}; // 회원가입 const SignUp = rest.post(`${BASE_URL}/user/auth/signup`, (req, res, ctx) => { @@ -41,6 +44,7 @@ const SignIn = rest.post(`${BASE_URL}/user/auth/signin`, (req, res, ctx) => { const ERROR = false; const successResponse = res( + ctx.cookie(devCookies.refreshTokenKey, '.'), ctx.status(200), ctx.delay(500), ctx.json({ @@ -65,17 +69,67 @@ const SignIn = rest.post(`${BASE_URL}/user/auth/signin`, (req, res, ctx) => { return ERROR ? errorResponse : successResponse; }); -export const GetMyInfo = rest.get('/api/user/auth/me', (req, res, ctx) => { - return res( - ctx.delay(), - ctx.status(200), - ctx.json({ - statusCode: 200, - result: { - user: users[0], - }, - }), - ); -}); +// 토큰 재발급 +const ReissueToken = rest.post( + `${BASE_URL}/user/auth/refresh`, + (req, res, ctx) => { + // 응답 메세지 성공-실패를 토글하려면 이 값을 바꿔주세요. + const existsRefreshToken = !!req.cookies[devCookies.refreshTokenKey]; + + const ERROR = !existsRefreshToken || false; + const isUnknownError = true; + + const successResponse = res( + ctx.status(200), + ctx.delay(1000), + ctx.json({ + statusCode: 200, + result: { + accessToken: 'accessToken', + }, + }), + ); + + const unAuthErrorResponse = res( + ctx.status(401), + ctx.delay(1000), + ctx.json({ + statusCode: 401, + message: 'Unauthorized', + error: '', + }), + ); + + const unknownErrorResponse = res( + ctx.status(502), + ctx.delay(1000), + ctx.json({ + statusCode: 502, + message: 'Unknown', + error: '', + }), + ); + + const errorResponse = isUnknownError + ? unknownErrorResponse + : unAuthErrorResponse; + + return ERROR ? errorResponse : successResponse; + }, +); + +export const GetMyInfo = rest.get( + `${BASE_URL}/user/auth/me`, + (req, res, ctx) => { + return res( + ctx.delay(300), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: users[0], + }), + ); + }, +); -export default [SignUp, SignIn, GetMyInfo]; +export default [SignUp, SignIn, GetMyInfo, ReissueToken]; diff --git a/client/src/mocks/handlers/DM.js b/client/src/mocks/handlers/DM.js new file mode 100644 index 00000000..a8e6b504 --- /dev/null +++ b/client/src/mocks/handlers/DM.js @@ -0,0 +1,23 @@ +import { API_URL } from '@constants/url'; +import { rest } from 'msw'; + +import { users } from '../data/users'; + +const GetDirectMessages = rest.get( + `${API_URL}/api/user/dms`, + (req, res, ctx) => { + return res( + ctx.delay(), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: [...users.slice(0, 5)].map((user, idx) => ({ + _id: idx, + user, + })), + }), + ); + }, +); + +export default [GetDirectMessages]; diff --git a/client/src/mocks/handlers/Friend.js b/client/src/mocks/handlers/Friend.js index 8ea10e21..71558100 100644 --- a/client/src/mocks/handlers/Friend.js +++ b/client/src/mocks/handlers/Friend.js @@ -8,19 +8,13 @@ const BASE_URL = `${API_URL}/api`; const GetFollowings = rest.get( `${BASE_URL}/user/followings`, (req, res, ctx) => { - const query = req.url.searchParams.get('query') ?? ''; - return res( ctx.delay(), ctx.status(200), ctx.json({ statusCode: 200, result: { - followings: query - ? users.filter(({ nickname }) => - nickname.toUpperCase().includes(query.toUpperCase()), - ) - : users, + followings: users, }, }), ); @@ -34,7 +28,7 @@ const UpdateFollowing = rest.post( const idx = users.findIndex((user) => user._id === userId); users.splice(idx, 1); - console.log(users); + return res( ctx.delay(), ctx.status(200), @@ -46,6 +40,19 @@ const UpdateFollowing = rest.post( }, ); -const FriendHandlers = [GetFollowings, UpdateFollowing]; +const GetFollowers = rest.get(`${BASE_URL}/user/followers`, (req, res, ctx) => { + return res( + ctx.delay(), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: { + followers: users, + }, + }), + ); +}); + +const FriendHandlers = [GetFollowings, UpdateFollowing, GetFollowers]; export default FriendHandlers; diff --git a/client/src/mocks/handlers/User.js b/client/src/mocks/handlers/User.js new file mode 100644 index 00000000..358eae5e --- /dev/null +++ b/client/src/mocks/handlers/User.js @@ -0,0 +1,25 @@ +import { API_URL } from '@constants/url'; +import { rest } from 'msw'; + +import { users } from '../data/users'; + +const GetFilteredUsers = rest.get(`${API_URL}/api/users`, (req, res, ctx) => { + const search = req.url.searchParams.get('search').toUpperCase(); + + return res( + ctx.delay(), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: { + users: users.filter( + (user) => + user.id.toUpperCase().includes(search) || + user.nickname.toUpperCase().includes(search), + ), + }, + }), + ); +}); + +export default [GetFilteredUsers]; diff --git a/client/src/mocks/handlers/index.js b/client/src/mocks/handlers/index.js index 53cf66eb..a411c0e4 100644 --- a/client/src/mocks/handlers/index.js +++ b/client/src/mocks/handlers/index.js @@ -1,4 +1,11 @@ import AuthHandlers from './Auth'; +import DMHandlers from './DM'; import FriendHandlers from './Friend'; +import UserHandlers from './User'; -export const handlers = [...AuthHandlers, ...FriendHandlers]; +export const handlers = [ + ...AuthHandlers, + ...FriendHandlers, + ...UserHandlers, + ...DMHandlers, +]; diff --git a/client/src/pages/AuthorizedLayer/index.tsx b/client/src/pages/AuthorizedLayer/index.tsx new file mode 100644 index 00000000..5dec1805 --- /dev/null +++ b/client/src/pages/AuthorizedLayer/index.tsx @@ -0,0 +1,33 @@ +import { useMyInfo } from '@hooks/useMyInfoQuery'; +import useReissueTokenMutation from '@hooks/useReissueTokenMutation'; +import { useTokenStore } from '@stores/tokenStore'; +import React, { useEffect } from 'react'; +import { Outlet, useNavigate } from 'react-router-dom'; + +/** + * ## 로그인 한 유저들만 머무를 수 있는 페이지. + * - 새로고침시 토큰 갱신을 시도하며, 로그인하지 않은(유저 상태나 액세스 토큰 상태가 없는) 유저가 접근하면 **`/`** 로 리다이렉트된다. + * - 토큰 갱신 요청시, 유효하지 않은 토큰 에러가 발생하면 **`/sign-in`** 으로 리다이렉트 된다. + * - 토큰 갱신 요청시, 알 수 없는 에러가 발생하면 **`/unknown-error`** 로 리다이렉트 된다. + */ +const AuthorizedLayer = () => { + const user = useMyInfo(); + + const accessToken = useTokenStore((state) => state.accessToken); + const navigate = useNavigate(); + + const reissueTokenMutation = useReissueTokenMutation(() => { + navigate('/sign-in', { state: { alreadyTriedReissueToken: true } }); + }, '/unknown-error'); + + useEffect(() => { + if (user || accessToken) return; + + reissueTokenMutation.mutate(); + }, []); + + if (!user && !accessToken) return
    로딩중...
    ; // Spinner 넣기 + return ; +}; + +export default AuthorizedLayer; diff --git a/client/src/pages/Followers/index.tsx b/client/src/pages/Followers/index.tsx deleted file mode 100644 index 99df5f18..00000000 --- a/client/src/pages/Followers/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const Followers = () => { - return
    Followers
    ; -}; - -export default Followers; diff --git a/client/src/pages/Friends/index.tsx b/client/src/pages/Friends/index.tsx index 61faf47e..dd16ff97 100644 --- a/client/src/pages/Friends/index.tsx +++ b/client/src/pages/Friends/index.tsx @@ -1,14 +1,18 @@ -import FollowingTab from '@features/FollowingTab'; -import Followers from '@pages/Followers'; -import UserSearch from '@pages/UserSearch'; +import type { ReactNode } from 'react'; + +import Followers from '@layouts/Followers'; +import Followings from '@layouts/Followings'; +import UserSearch from '@layouts/UserSearch'; import React, { useState } from 'react'; +// TODO: 네이밍 생각해보기 const TAB = { FOLLOWINGS: 'followings', FOLLOWERS: 'followers', USER_SEARCH: 'user-search', -}; +} as const; +// TODO: 필드 이름 수정하기 const tabs = [ { name: '팔로잉', @@ -22,10 +26,11 @@ const tabs = [ name: '사용자 검색', tab: 'user-search', }, -]; +] as const; -const TabPanel: Record = { - [TAB.FOLLOWINGS]: , +// TODO: 컴포넌트 이름 수정하기 (FollowingTab -> Followings) +const TabPanel: Record = { + [TAB.FOLLOWINGS]: , [TAB.FOLLOWERS]: , [TAB.USER_SEARCH]: , }; @@ -33,11 +38,13 @@ const TabPanel: Record = { const DEFAULT_TAB = TAB.FOLLOWINGS; const Friends = () => { - const [tab, setTab] = useState(DEFAULT_TAB); + const [tab, setTab] = useState<'followings' | 'followers' | 'user-search'>( + DEFAULT_TAB, + ); return ( -
    -
    +
    +
      {tabs.map(({ name, tab: t }) => (
    • { ))}
    -
    -
    +
    +
    {TabPanel[tab]}
    diff --git a/client/src/pages/Home/index.tsx b/client/src/pages/Home/index.tsx index bac9f50a..aeb911e0 100644 --- a/client/src/pages/Home/index.tsx +++ b/client/src/pages/Home/index.tsx @@ -1,14 +1,16 @@ -import Gnb from '@features/Gnb'; -import Sidebar from '@features/Sidebar'; +import CreateCommunityModal from '@components/Modals/CreateCommunityModal'; +import Gnb from '@layouts/Gnb'; +import Sidebar from '@layouts/Sidebar'; import React from 'react'; import { Outlet } from 'react-router-dom'; const Home = () => { return ( -
    +
    +
    ); }; diff --git a/client/src/pages/Root/index.tsx b/client/src/pages/Root/index.tsx index 7c44f79c..ced43a86 100644 --- a/client/src/pages/Root/index.tsx +++ b/client/src/pages/Root/index.tsx @@ -1,7 +1,21 @@ +import { useMyInfo } from '@hooks/useMyInfoQuery'; +import { useTokenStore } from '@stores/tokenStore'; import React from 'react'; +import { Navigate } from 'react-router-dom'; +/** + * @description + * ## 인증 상태에 따라 리다이렉트 분기처리하는 페이지 + * - 로그인 되어있으면 **`/dms`** 로 이동한다. + * - 로그인 되어있지 않으면, **`/sign-in`** 으로 이동한다. + * - 조건문에 user || accessToken 중 하나라도 없으면 **`/sign-in`** -> **`/`** -> **`/sign-in`** ... 무한루프 발생함. + */ const Root = () => { - return
    ; + const user = useMyInfo(); + const accessToken = useTokenStore((state) => state.accessToken); + + if (user || accessToken) return ; + return ; }; export default Root; diff --git a/client/src/pages/SignIn/index.tsx b/client/src/pages/SignIn/index.tsx index 9848110e..84d87bc2 100644 --- a/client/src/pages/SignIn/index.tsx +++ b/client/src/pages/SignIn/index.tsx @@ -1,91 +1,51 @@ +import type { SignInRequest } from '@apis/auth'; + import AuthInput from '@components/AuthInput'; import Button from '@components/Button'; import ErrorMessage from '@components/ErrorMessage'; import Logo from '@components/Logo'; import TextButton from '@components/TextButton'; -import { API_URL } from '@constants/url'; +import REGEX from '@constants/regex'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import useSignInMutation from '@hooks/useSignInMutation'; import { useTokenStore } from '@stores/tokenStore'; -import { useMutation } from '@tanstack/react-query'; -import axios, { AxiosError } from 'axios'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { Navigate, useNavigate } from 'react-router-dom'; -import { toast } from 'react-toastify'; - -interface SignInFields { - id: string; - password: string; -} - -interface SuccessResponse { - statusCode: number; - result: T; -} - -type SignInApi = ( - fields: SignInFields, -) => Promise>; - -const endPoint = `${API_URL}/api/user/auth/signin`; +import { useNavigate } from 'react-router-dom'; -const signInApi: SignInApi = ({ id, password }) => { - return axios - .post(endPoint, { id, password }) - .then((response) => response.data); +const signUpFormDefaultValues = { + id: '', + password: '', }; -// 액세스 토큰으로 다시 유저 정보 요청해야함 -// _id, id(이메일), nickname, status, profileUrl, description const SignIn = () => { - const accessToken = useTokenStore((state) => state.accessToken); const setAccessToken = useTokenStore((state) => state.setAccessToken); - const { control, handleSubmit, reset } = useForm({ + const { control, handleSubmit, reset } = useForm({ mode: 'all', - defaultValues: { - id: '', - password: '', - }, + defaultValues: signUpFormDefaultValues, }); const navigate = useNavigate(); - const signInMutate = useMutation(['signIn'], signInApi, { + const signInMutation = useSignInMutation({ onSuccess: (data) => { setAccessToken(data.result.accessToken); + navigate('/dms'); }, onError: (error) => { reset(); - if (error instanceof AxiosError) { - const errorMessage = - error?.response?.data?.message || '에러가 발생했습니다!'; - - if (Array.isArray(errorMessage)) { - errorMessage.forEach((message) => { - toast.error(message); - }); - return; - } - - toast.error(errorMessage); - return; - } - - toast.error('Unknown Error'); + defaultErrorHandler(error); }, }); - const handleSubmitSignInForm = ({ id, password }: SignInFields) => { - signInMutate.mutate({ id, password }); + const handleSubmitSignInForm = ({ id, password }: SignInRequest) => { + signInMutation.mutate({ id, password }); }; const handleNavigateSignUpPage = () => { navigate('/sign-up'); }; - if (accessToken) { - return ; - } - return (
    @@ -102,7 +62,7 @@ const SignIn = () => { control={control} rules={{ pattern: { - value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, + value: REGEX.EMAIL, message: '아이디는 이메일 형식으로 입력해야 합니다!', }, }} @@ -149,7 +109,7 @@ const SignIn = () => { size="md" type="submit" minWidth={340} - disabled={signInMutate.isLoading} + disabled={signInMutation.isLoading} > 로그인 diff --git a/client/src/pages/SignUp/index.tsx b/client/src/pages/SignUp/index.tsx index 00c1b267..59163f27 100644 --- a/client/src/pages/SignUp/index.tsx +++ b/client/src/pages/SignUp/index.tsx @@ -1,84 +1,52 @@ +import type { SignUpRequest } from '@apis/auth'; + import AuthInput from '@components/AuthInput'; import Button from '@components/Button'; import ErrorMessage from '@components/ErrorMessage'; import Logo from '@components/Logo'; import SuccessMessage from '@components/SuccessMessage'; import TextButton from '@components/TextButton'; -import { API_URL } from '@constants/url'; -import { useMutation } from '@tanstack/react-query'; -import axios, { AxiosError } from 'axios'; +import REGEX from '@constants/regex'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import useSignUpMutation from '@hooks/useSignUpMutation'; import React from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; -interface SignUpFields { - id: string; - nickname: string; - password: string; +interface SignUpFormFields extends SignUpRequest { passwordCheck: string; } -interface SuccessResponse { - statusCode: number; - result: T; -} - -type SignUpApi = ( - fields: Omit, -) => Promise>; - -const endPoint = `${API_URL}/api/user/auth/signup`; - -const signUpApi: SignUpApi = ({ id, nickname, password }) => { - return axios - .post(endPoint, { id, nickname, password }) - .then((response) => response.data); +const signUpFormDefaultValues = { + id: '', + nickname: '', + password: '', + passwordCheck: '', }; const SignUp = () => { - // TODO: 리팩토링 하자 - const { control, handleSubmit, watch, reset } = useForm({ + const { control, handleSubmit, watch, reset } = useForm({ mode: 'all', - defaultValues: { - id: '', - nickname: '', - password: '', - passwordCheck: '', - }, + defaultValues: signUpFormDefaultValues, }); + const password = watch('password'); + const navigate = useNavigate(); - const signUpMutate = useMutation(['signUp'], signUpApi, { + const signUpMutation = useSignUpMutation({ onSuccess: () => { toast.success('회원가입에 성공했습니다.'); reset(); }, onError: (error) => { - if (error instanceof AxiosError) { - const errorMessage = - error?.response?.data?.message || '에러가 발생했습니다!'; - - if (Array.isArray(errorMessage)) { - errorMessage.forEach((message) => { - toast.error(message); - }); - return; - } - - toast.error(errorMessage); - return; - } - - toast.error('Unknown Error'); + defaultErrorHandler(error); }, }); - const password = watch('password'); - - const handleSubmitSignUpForm = (fields: SignUpFields) => { - signUpMutate.mutate(fields); + const handleSubmitSignUpForm = (fields: SignUpFormFields) => { + signUpMutation.mutate(fields); }; const handleNavigateSignInPage = () => { @@ -101,7 +69,7 @@ const SignUp = () => { control={control} rules={{ pattern: { - value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, + value: REGEX.EMAIL, message: '아이디는 이메일 형식으로 입력해야 합니다!', }, required: '필수 요소입니다!', @@ -220,7 +188,7 @@ const SignUp = () => { size="md" type="submit" minWidth={340} - disabled={signUpMutate.isLoading} + disabled={signUpMutation.isLoading} > 회원가입 diff --git a/client/src/pages/UnAuthorizedLayer/index.tsx b/client/src/pages/UnAuthorizedLayer/index.tsx new file mode 100644 index 00000000..94963043 --- /dev/null +++ b/client/src/pages/UnAuthorizedLayer/index.tsx @@ -0,0 +1,37 @@ +import { useMyInfo } from '@hooks/useMyInfoQuery'; +import useReissueTokenMutation from '@hooks/useReissueTokenMutation'; +import { useTokenStore } from '@stores/tokenStore'; +import React, { useEffect, useState } from 'react'; +import { Outlet, Navigate, useLocation } from 'react-router-dom'; + +/** + * ## 로그인 하지 않은 유저들만 머무를 수 있는 페이지. + * - 새로고침시 토큰 갱신을 시도하며, 로그인한(유저 상태나 액세스 토큰 상태가 있는) 유저가 접근하면 **`/`** 로 리다이렉트된다. + * - 토큰 갱신 요청시, 유효하지 않은 토큰 에러나 알 수 없는 에러가 발생하면 페이지 이동 없이 그대로 유지한다. + */ +const UnAuthorizedLayer = () => { + const user = useMyInfo(); + const location = useLocation(); + + const accessToken = useTokenStore((state) => state.accessToken); + const [isTryingReissueToken, setIsTryingReissueToken] = useState(true); + + const handleReissueTokenError = () => setIsTryingReissueToken(false); + + const reissueTokenMutation = useReissueTokenMutation( + handleReissueTokenError, + handleReissueTokenError, + ); + + useEffect(() => { + if (user) return; + reissueTokenMutation.mutate(); + }, []); + + if (user || accessToken) return ; + if (location.state?.alreadyTriedReissueToken) return ; + if (isTryingReissueToken) return
    로딩중...
    ; + return ; +}; + +export default UnAuthorizedLayer; diff --git a/client/src/pages/UnknownError/index.tsx b/client/src/pages/UnknownError/index.tsx new file mode 100644 index 00000000..f7f0485a --- /dev/null +++ b/client/src/pages/UnknownError/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const UnknownError = () => { + console.log(`[${location.pathname}]`); + + return
    Unknown Error
    ; +}; + +export default UnknownError; diff --git a/client/src/pages/UserSearch/index.tsx b/client/src/pages/UserSearch/index.tsx deleted file mode 100644 index 356d07d3..00000000 --- a/client/src/pages/UserSearch/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const UserSearch = () => { - return
    UserSearch
    ; -}; - -export default UserSearch; diff --git a/client/src/queryKeyCreator.ts b/client/src/queryKeyCreator.ts new file mode 100644 index 00000000..31e04bce --- /dev/null +++ b/client/src/queryKeyCreator.ts @@ -0,0 +1,22 @@ +const directMessageQueryKey = { + all: ['directMessages'] as const, + list: () => [...directMessageQueryKey.all] as const, + detail: (id: string) => [...directMessageQueryKey.all, id] as const, +} as const; + +const queryKeyCreator = { + me: () => ['me'] as const, + signUp: () => ['signUp'] as const, + signIn: () => ['signIn'] as const, + followings: (): [string] => ['followings'], + followers: (): [string] => ['followers'], + reissueToken: () => ['reissueToken'] as const, + userSearch: (filter: string) => ['userSearch', { filter }], + directMessage: directMessageQueryKey, +} as const; + +export default queryKeyCreator; + +type QueryKeyCreatorType = typeof queryKeyCreator; +export type QueryKeyCreator = + QueryKeyCreatorType[T]; diff --git a/client/src/stores/modalSlice.ts b/client/src/stores/modalSlice.ts new file mode 100644 index 00000000..4fca1f10 --- /dev/null +++ b/client/src/stores/modalSlice.ts @@ -0,0 +1,15 @@ +import type { StateCreator } from 'zustand'; + +export interface ModalSlice { + createCommunityModal: { + open: boolean; + }; +} + +export const createModalSlice: StateCreator = ( + set, +) => ({ + createCommunityModal: { + open: false, + }, +}); diff --git a/client/src/stores/rootStore.ts b/client/src/stores/rootStore.ts new file mode 100644 index 00000000..f4421b24 --- /dev/null +++ b/client/src/stores/rootStore.ts @@ -0,0 +1,14 @@ +import type { ModalSlice } from '@stores/modalSlice'; + +import store from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import { createModalSlice } from './modalSlice'; + +export type Store = ModalSlice; + +export const useStore = store()( + devtools((...a) => ({ + ...createModalSlice(...a), + })), +); diff --git a/client/src/stores/tokenStore.ts b/client/src/stores/tokenStore.ts index b13ad94a..854fdd91 100644 --- a/client/src/stores/tokenStore.ts +++ b/client/src/stores/tokenStore.ts @@ -9,7 +9,7 @@ type TokenStore = { export const tokenStore = createVanillaStore()( devtools((set) => ({ - accessToken: null, + accessToken: process.env.NODE_ENV === 'development' ? 'null' : null, setAccessToken: (newAccessToken) => set(() => ({ accessToken: newAccessToken })), })), diff --git a/client/src/types/apis/process.d.ts b/client/src/types/apis/process.d.ts new file mode 100644 index 00000000..cac93912 --- /dev/null +++ b/client/src/types/apis/process.d.ts @@ -0,0 +1,6 @@ +declare namespace NodeJS { + interface ProcessEnv { + NODE_ENV?: string; + API_URL?: string; + } +} diff --git a/client/src/types/apis/response.ts b/client/src/types/apis/response.ts new file mode 100644 index 00000000..4d63b12f --- /dev/null +++ b/client/src/types/apis/response.ts @@ -0,0 +1,10 @@ +export interface SuccessResponse { + statusCode: number; + result: T; +} + +export interface ErrorResponse { + statusCode: number; + message: string | string[]; + error: string; +} diff --git a/client/src/utils/axios.ts b/client/src/utils/axios.ts new file mode 100644 index 00000000..b78d783e --- /dev/null +++ b/client/src/utils/axios.ts @@ -0,0 +1,48 @@ +import { API_URL } from '@constants/url'; +import { tokenStore } from '@stores/tokenStore'; +import axios from 'axios'; + +const { getState } = tokenStore; + +/** + * ## Asnity api server 전용 Axios instance + * - `baseURL`은 Asnity server 이다. 따라서 엔드포인트 작성시 `baseURL`이후 부분만 적는다. + * - Api 요청시 전역 상태에서 관리하는 accessToken을 Authorization header에 삽입하고 보낸다. + * - accessToken이 없다면 요청 Promise가 Reject된다. + * - 토큰 만료 응답시 Response interceptors에서 재발급 후 Request 재요청 하는 로직은 추후에 추가할 예정. + */ +export const tokenAxios = axios.create({ + baseURL: API_URL, +}); + +/** + * ## Asnity api server 전용 Axios instance + * - `baseURL`은 Asnity server 이다. 따라서 엔드포인트 작성시 `baseURL`이후 부분만 적는다. + * - accessToken이 필요없는 요청을 보낼 때 사용한다. + */ +export const publicAxios = axios.create({ + baseURL: API_URL, +}); + +tokenAxios.interceptors.request.use( + (config) => { + const { accessToken } = getState(); + + console.warn('tokenAxios 사용 확인용 로그. 무시하시면 됩니다.'); + + if (!accessToken) { + console.warn(`accessToken이 없습니다.`); + return Promise.reject( + `tokenAxios instance로 요청을 보내기 위해서는 accessToken이 필요합니다.`, + ); + } + + config.headers = { + Authorization: `Bearer ${accessToken}`, + }; + return config; + }, + function (error) { + return Promise.reject(error); + }, +); diff --git a/client/tsconfig.json b/client/tsconfig.json index 929f22a5..d7bde1a6 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "lib": ["DOM"], + "esModuleInterop": true, + "moduleResolution": "Node", + "lib": ["DOM", "es2015.iterable"], "jsx": "react-jsx", "baseUrl": ".", "outDir": "./build", @@ -14,11 +16,13 @@ "@icons/*": ["src/assets/icons/*"], "@constants/*": ["src/constants/*"], "@apis/*": ["src/apis/*"], + "@hooks/*": ["src/hooks/*"], + "@errors/*": ["src/errors/*"], + "@@types/*": ["src/types/*"], + "@/*": ["src/*"] } }, "include": ["src", "config"], "exclude": ["node_modules", "build", "dist"], - "references": [ - { "path": "../shared" } - ] -} \ No newline at end of file + "references": [{ "path": "../shared" }] +} diff --git a/shared/lib/user.ts b/shared/lib/user.ts index 2b1bf864..ba778278 100644 --- a/shared/lib/user.ts +++ b/shared/lib/user.ts @@ -1,15 +1,10 @@ +export type UserStatus = 'online' | 'offline' | 'afk'; + export interface User { _id: string; id: string; nickname: string; - status: string; + status: UserStatus; profileUrl: string; descrption: string; } - -export interface GetUsersReponse { - statusCode: number; - result: { - users: User[]; - }; -} diff --git a/yarn.lock b/yarn.lock index f9ae5416..ad0153df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1844,6 +1844,11 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@faker-js/faker@^7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07" + integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw== + "@graphql-tools/merge@8.3.11": version "8.3.11" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.11.tgz#f5eab764e8d7032c1b7e32d5dc6dea5b2f5bb21e" @@ -3223,6 +3228,13 @@ dependencies: "@types/react" "*" +"@types/react-modal@^3.13.1": + version "3.13.1" + resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a" + integrity sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^18.0.25": version "18.0.25" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.25.tgz#8b1dcd7e56fe7315535a4af25435e0bb55c8ae44" @@ -3674,6 +3686,11 @@ acorn@^8.0.4, acorn@^8.1.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== +add-px-to-style@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" + integrity sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -5157,6 +5174,15 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +dom-css@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dom-css/-/dom-css-2.1.0.tgz#fdbc2d5a015d0a3e1872e11472bbd0e7b9e6a202" + integrity sha512-w9kU7FAbaSh3QKijL6n59ofAhkkmMJ31GclJIz/vyQdjogfyxcB6Zf8CZyibOERI5o0Hxz30VmJS7+7r5fEj2Q== + dependencies: + add-px-to-style "1.0.0" + prefix-style "2.0.1" + to-camel-case "1.0.0" + dom-serializer@^1.0.1: version "1.4.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" @@ -5808,6 +5834,11 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +exenv@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== + exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -8151,7 +8182,7 @@ logform@^2.3.2, logform@^2.4.0: safe-stable-stringify "^2.3.1" triple-beam "^1.3.0" -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -9006,6 +9037,11 @@ pause@0.0.1: resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -9330,6 +9366,11 @@ postcss@^8.4.17, postcss@^8.4.18, postcss@^8.4.19: picocolors "^1.0.0" source-map-js "^1.0.2" +prefix-style@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" + integrity sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -9406,7 +9447,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.8.1: +prop-types@^15.5.10, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -9473,6 +9514,13 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +raf@^3.1.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -9495,6 +9543,15 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +react-custom-scrollbars-2@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/react-custom-scrollbars-2/-/react-custom-scrollbars-2-4.5.0.tgz#cff18e7368bce9d570aea0be780045eda392c745" + integrity sha512-/z0nWAeXfMDr4+OXReTpYd1Atq9kkn4oI3qxq3iMXGQx1EEfwETSqB8HTAvg1X7dEqcCachbny1DRNGlqX5bDQ== + dependencies: + dom-css "^2.0.0" + prop-types "^15.5.10" + raf "^3.1.0" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -9523,6 +9580,21 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-lifecycles-compat@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-modal@^3.16.1: + version "3.16.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.16.1.tgz#34018528fc206561b1a5467fc3beeaddafb39b2b" + integrity sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg== + dependencies: + exenv "^1.2.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.0" + warning "^4.0.3" + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" @@ -10638,11 +10710,23 @@ tmpl@1.0.5: resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== +to-camel-case@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" + integrity sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q== + dependencies: + to-space-case "^1.0.0" + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== +to-no-case@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" + integrity sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -10650,6 +10734,13 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +to-space-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" + integrity sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA== + dependencies: + to-no-case "^1.0.0" + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" @@ -11011,6 +11102,13 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +warning@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watchpack@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"