diff --git a/.github/workflows/client_dev.yml b/.github/workflows/client_dev.yml new file mode 100644 index 00000000..d2152989 --- /dev/null +++ b/.github/workflows/client_dev.yml @@ -0,0 +1,31 @@ +on: + pull_request: + branches: + - dev-fe + types: + - closed + +jobs: + pull_nCloud_client-dev: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: execute remot ssh + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NCLOUD_REMOTE_IP }} + username: ${{ secrets.NCLOUD_REMOTE_SSH_ID }} + password: ${{ secrets.NCLOUD_REMOTE_SSH_PASSWORD }} + port: ${{ secrets.NCLOUD_REMOTE_SSH_PORT }} + script: | + cd web24-Asnity + git stash + git pull origin dev-fe + ls -al + export NVM_DIR=~/.nvm + source ~/.nvm/nvm.sh + yarn install + rm -rf /usr/build + yarn client build + cp -r ./client/build /usr \ No newline at end of file diff --git a/client/config/webpack.dev.proxy.ts b/client/config/webpack.dev.proxy.ts new file mode 100644 index 00000000..8fdb831e --- /dev/null +++ b/client/config/webpack.dev.proxy.ts @@ -0,0 +1,31 @@ +import 'webpack-dev-server'; + +import type { Configuration } from 'webpack'; + +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: { + hot: true, + open: true, + historyApiFallback: true, + proxy: { + '/': 'http://49.50.167.202/', + }, + }, +}; + +export default merge(common, config); diff --git a/client/package.json b/client/package.json index b22b0ec2..bcefa1a0 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:proxy": "cross-env NODE_ENV=development webpack serve --config ./config/webpack.dev.proxy.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", @@ -15,6 +16,7 @@ "@tanstack/react-query": "^4.16.1", "axios": "^1.1.3", "classnames": "^2.3.2", + "immer": "^9.0.16", "react": "^18.2.0", "react-custom-scrollbars-2": "^4.5.0", "react-dom": "^18.2.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 7b823fb5..10d78ea1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import AccessDenied from '@pages/AccessDenied'; import AuthorizedLayer from '@pages/AuthorizedLayer'; +import Channel from '@pages/Channel'; import Community from '@pages/Community'; import DM from '@pages/DM'; import DMRoom from '@pages/DMRoom'; @@ -17,6 +18,8 @@ import { Route, createBrowserRouter, createRoutesFromElements, + Navigate, + Outlet, } from 'react-router-dom'; const router = createBrowserRouter( @@ -27,12 +30,46 @@ const router = createBrowserRouter( }> }> } /> - } /> + + + {/* TODO: roomId가 올바른지 검증하기 */} + + } + > + } /> + + + {/* TODO: communities/ 로 이동했을 때 리다이렉트할 url 정하기 */} + + + + {/* TODO: communityId가 올바른지 검증하기 */} + + } + > + } /> + + } /> + + + {/* TODO: roomId가 올바른지 검증하기 */} + + } + > + } /> + + + - } - /> }> diff --git a/client/src/apis/auth.ts b/client/src/apis/auth.ts index 7c9e17bc..ceea3c74 100644 --- a/client/src/apis/auth.ts +++ b/client/src/apis/auth.ts @@ -1,7 +1,6 @@ import type { SuccessResponse } from '@@types/apis/response'; -import { API_URL } from '@constants/url'; -import axios from 'axios'; +import { publicAxios } from '@utils/axios'; export interface SignUpRequest { id: string; @@ -13,20 +12,18 @@ export interface SignUpResult { message: string; } -export type SignUp = ( - fields: SignUpRequest, -) => Promise>; +export type SignUp = (fields: SignUpRequest) => Promise; export const signUp: SignUp = ({ id, nickname, password }) => { - const endPoint = `${API_URL}/api/user/auth/signup`; + const endPoint = `/api/user/auth/signup`; - return axios - .post(endPoint, { id, nickname, password }) - .then((response) => response.data); + return publicAxios + .post>(endPoint, { id, nickname, password }) + .then((response) => response.data.result); }; export interface SignInRequest { - id: string; + id: string; // username password: string; } @@ -35,32 +32,35 @@ export interface SignInResult { accessToken: string; } -export type SignIn = ( - fields: SignInRequest, -) => Promise>; +export type SignIn = (fields: SignInRequest) => Promise; export const signIn: SignIn = ({ id, password }) => { - const endPoint = `${API_URL}/api/user/auth/signin`; + const endPoint = `/api/user/auth/signin`; - return axios - .post(endPoint, { id, password }, { withCredentials: true }) - .then((response) => response.data); + return publicAxios + .post>( + endPoint, + { id, password }, + { withCredentials: true }, + ) + .then((response) => response.data.result); }; -// 액세스 토큰으로 다시 유저 정보 요청해야함 -// _id, id(이메일), nickname, status, profileUrl, description +// 이후 DM 페이지에서 액세스 토큰으로 다시 유저 정보 요청해야함 export interface ReissueTokenResult { accessToken: string; } -export type ReissueToken = () => Promise>; +export type ReissueToken = () => Promise; export const reissueToken: ReissueToken = () => { - const endPoint = `${API_URL}/api/user/auth/refresh`; + const endPoint = `/api/user/auth/refresh`; - return axios - .post(endPoint, {}, { withCredentials: true }) - .then((response) => { - return response.data; - }); + return publicAxios + .post>( + endPoint, + {}, + { withCredentials: true }, + ) + .then((response) => response.data.result); }; diff --git a/client/src/apis/channel.ts b/client/src/apis/channel.ts new file mode 100644 index 00000000..291e328e --- /dev/null +++ b/client/src/apis/channel.ts @@ -0,0 +1,67 @@ +import type { SuccessResponse } from '@@types/apis/response'; +import type { User, UserUID } from '@apis/user'; + +import { tokenAxios } from '@utils/axios'; + +export interface JoinedChannel { + _id: string; + managerId: UserUID; + name: string; + isPrivate: boolean; + description: string; + lastRead: boolean; // NOTE: get communities에는 있는데, get channel에는 없는 프로퍼티. + type: string; // TODO: DM or Channel -> DM 구현할 때 타입 구체화 + createdAt: string; +} + +export interface Channel extends JoinedChannel { + users: User[]; +} + +export type GetChannelResult = Channel; +export type GetChannelResponse = SuccessResponse; +export type GetChannel = (channelId: string) => Promise; + +export const getChannel: GetChannel = (channelId: string) => { + const endPoint = `/api/channels/${channelId}`; + + return tokenAxios + .get(endPoint) + .then((response) => response.data.result); +}; + +export interface CreateChannelResult extends JoinedChannel {} +export type CreateChannelResponse = SuccessResponse; +export interface CreateChannelRequest { + communityId: string; + name: string; + isPrivate: boolean; + description: string; + profileUrl: string; +} + +export type CreateChannel = ( + fields: CreateChannelRequest, +) => Promise; + +export const createChannel: CreateChannel = ({ + communityId, + name, + isPrivate, + description = '', + profileUrl = '', +}) => { + const endPoint = `/api/channel`; + const type = 'Channel'; + + return tokenAxios + .post(endPoint, { + communityId, + name, + isPrivate, + description, + profileUrl, + type, + }) + .then((response) => response.data.result); +}; diff --git a/client/src/apis/chat.ts b/client/src/apis/chat.ts new file mode 100644 index 00000000..9c2ebfc9 --- /dev/null +++ b/client/src/apis/chat.ts @@ -0,0 +1,34 @@ +import type { SuccessResponse } from '@@types/apis/response'; + +import { tokenAxios } from '@utils/axios'; + +export type ChatType = 'TEXT' | 'IMAGE'; + +export interface Chat { + id: string; + type: ChatType; + content: string; + senderId: string; + updatedAt: string; + createdAt: string; + deletedAt?: string; +} + +export type GetChatsResult = { + chat?: Chat[]; + prev?: number; +}; + +export type GetChatsResponse = SuccessResponse; +export type GetChats = ( + channelId: string, + prev?: number, +) => Promise; + +export const getChats: GetChats = (channelId, prev) => { + const endPoint = `/api/chat/${channelId}`; + + return tokenAxios + .get(endPoint, { params: { prev } }) + .then((response) => response.data.result); +}; diff --git a/client/src/apis/community.ts b/client/src/apis/community.ts new file mode 100644 index 00000000..08a2ff91 --- /dev/null +++ b/client/src/apis/community.ts @@ -0,0 +1,109 @@ +import type { SuccessResponse } from '@@types/apis/response'; +import type { JoinedChannel } from '@apis/channel'; +import type { UserUID } from '@apis/user'; + +import { tokenAxios } from '@utils/axios'; + +export interface CommunitySummary { + _id: string; + name: string; + profileUrl: string; + description: string; + managerId: string; + channels: JoinedChannel[]; +} + +export type CommunitySummaries = CommunitySummary[]; + +export type GetCommunitiesResult = { + communities: CommunitySummary[]; +}; + +export type GetCommunities = () => Promise; + +export const getCommunities: GetCommunities = () => { + const endPoint = `/api/communities`; + + return tokenAxios + .get>(endPoint) + .then((response) => response.data.result.communities); +}; + +export interface CreateCommunityRequest { + name: string; + description: string; + profileUrl?: string; +} + +export interface CreateCommunityResult extends CommunitySummary { + __v: 0; + createdAt: string; + updatedAt: string; + channels: []; + users: Array; +} + +export type CreateCommunity = ( + fields: CreateCommunityRequest, +) => Promise; + +export const createCommunity: CreateCommunity = ({ + name, + description, + profileUrl = '', +}) => { + const endPoint = `/api/community`; + + return tokenAxios + .post(endPoint, { name, description, profileUrl }) + .then((response) => response.data.result); +}; + +export interface RemoveCommunityResult { + message: string; +} + +export type RemoveCommunity = ( + communityId: string, +) => Promise; + +export const removeCommunity: RemoveCommunity = (communityId) => { + const endPoint = `/api/communities/${communityId}`; + + return tokenAxios.delete(endPoint).then((response) => response.data.result); +}; + +export interface LeaveCommunityResult { + message: string; +} + +export type LeaveCommunity = ( + communityId: string, +) => Promise; + +export const leaveCommunity: LeaveCommunity = (communityId) => { + const endPoint = `/api/communities/${communityId}/me`; + + return tokenAxios.delete(endPoint).then((response) => response.data.result); +}; + +export interface InviteCommunityRequest { + communityId: string; + userIds: Array; +} + +export interface InviteCommunityResult { + message: string; +} + +export type InviteCommunity = ( + fields: InviteCommunityRequest, +) => Promise; + +export const inviteCommunity: InviteCommunity = ({ communityId, userIds }) => { + const endPoint = `/api/communities/${communityId}/users`; + + return tokenAxios + .post>(endPoint, { users: userIds }) + .then((response) => response.data.result); +}; diff --git a/client/src/apis/dm.ts b/client/src/apis/dm.ts index a241d3c2..44bae2e2 100644 --- a/client/src/apis/dm.ts +++ b/client/src/apis/dm.ts @@ -1,16 +1,28 @@ -import type { User } from '@apis/user'; +import type { SuccessResponse } from '@@types/apis/response'; +import type { UserUID } from '@apis/user'; -import { API_URL } from '@constants/url'; -import axios from 'axios'; +import { tokenAxios } from '@utils/axios'; export interface DirectMessage { _id: string; - user: User; + name: string; + users: UserUID[]; + profileUrl: string; + description: string; + managerId: string; + isPrivate: boolean; + type: 'DM'; } export type GetDirectMessagesResult = DirectMessage[]; - +export type GetDirectMessagesResponse = + SuccessResponse; export type GetDirectMessages = () => Promise; -export const getDirectMessages: GetDirectMessages = () => - axios.get(`${API_URL}/api/user/dms`).then((res) => res.data.result); +export const getDirectMessages: GetDirectMessages = () => { + const endPoint = `/api/user/dms`; + + return tokenAxios + .get(endPoint) + .then((res) => res.data.result); +}; diff --git a/client/src/apis/user.ts b/client/src/apis/user.ts index a2e5f6d0..5ab3def1 100644 --- a/client/src/apis/user.ts +++ b/client/src/apis/user.ts @@ -1,9 +1,9 @@ import type { SuccessResponse } from '@@types/apis/response'; +import type { USER_STATUS } from '@constants/user'; -import { API_URL } from '@constants/url'; -import axios from 'axios'; +import { tokenAxios } from '@utils/axios'; -export type UserStatus = 'online' | 'offline' | 'afk'; +export type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS]; export interface User { _id: string; @@ -12,52 +12,102 @@ export interface User { status: UserStatus; profileUrl: string; description: string; + createdAt: string; } -export type MyInfoResult = User; +export type UserUID = User['_id']; -type GetMyInfo = () => Promise; +export type GetMyInfoResult = User; +export type GetMyInfoResponse = SuccessResponse; +export type GetMyInfo = () => Promise; export const getMyInfo: GetMyInfo = () => { - return axios - .get(`${API_URL}/api/user/auth/me`) + const endPoint = `/api/user/auth/me`; + + return tokenAxios + .get(endPoint) .then((response) => response.data.result); }; +export type Followings = User[]; export interface GetFollowingsResult { - followings: User[]; + followings: Followings; } export type GetFollowingsResponse = SuccessResponse; +export type GetFollowings = () => Promise; + +export const getFollowings: GetFollowings = () => { + const endPoint = `/api/user/followings`; -export const getFollowings = (): Promise => - axios.get(`${API_URL}/api/user/followings`).then((res) => res.data); + return tokenAxios + .get(endPoint) + .then((res) => res.data.result.followings); +}; export interface UpdateFollowingResult { message?: string; } export type UpdateFollowingResponse = SuccessResponse; - -export const updateFollowing = ( +export type UpdateFollowing = ( userId: string, -): Promise => - axios.post(`${API_URL}/api/user/following/${userId}`).then((res) => res.data); +) => Promise; -export interface GetFollowersResult { - followers: User[]; -} +// 유저를 팔로우한다. +// 유저가 팔로잉 상태라면 언팔로우 한다. (toggle) +export const updateFollowing: UpdateFollowing = (userId) => { + const endPoint = `/api/user/following/${userId}`; + return tokenAxios + .post(endPoint) + .then((res) => res.data.result); +}; + +export type Followers = User[]; +export type GetFollowersResult = { + followers: Followers; +}; export type GetFollowersResponse = SuccessResponse; +export type GetFollowers = () => Promise; + +export const getFollowers: GetFollowers = () => { + const endPoint = `/api/user/followers`; + + return tokenAxios + .get(endPoint) + .then((res) => res.data.result.followers); +}; -export const getFollowers = (): Promise => - axios.get(`${API_URL}/api/user/followers`).then((res) => res.data); export interface GetUsersParams { search: string; } -export interface GetUsersResult { +export type GetUsersResult = { + users: User[]; +}; +export type GetUsersResponse = SuccessResponse; +export type GetUsers = (params: GetUsersParams) => Promise; + +/** + * 여러 유저 검색에 사용됨 + */ +export const getUsers: GetUsers = (params) => { + const endPoint = `/api/users`; + + return tokenAxios + .get(endPoint, { params }) + .then((response) => response.data.result.users); +}; + +export interface GetCommunityUsersResult { users: User[]; } +export type GetCommunityUsersResponse = + SuccessResponse; +export type GetCommunityUsers = (communityId: string) => Promise; -export type GetUsersResponse = SuccessResponse; +export const getCommunityUsers: GetCommunityUsers = (communityId) => { + const endPoint = `/api/communities/${communityId}/users`; -export const GetUsers = (params: GetUsersParams): Promise => - axios.get(`${API_URL}/api/users`, { params }).then((res) => res.data); + return tokenAxios + .get(endPoint) + .then((response) => response.data.result.users); +}; diff --git a/client/src/components/AlertBox/index.tsx b/client/src/components/AlertBox/index.tsx new file mode 100644 index 00000000..f9d1ec77 --- /dev/null +++ b/client/src/components/AlertBox/index.tsx @@ -0,0 +1,42 @@ +import type { FC } from 'react'; + +import Button from '@components/Button'; +import React from 'react'; + +interface Props { + description: string; + onCancel: () => void; + onSubmit: () => void; + disabled?: boolean; +} + +const AlertBox: FC = ({ + onSubmit, + onCancel, + description, + disabled = false, +}) => { + return ( +
+

한번 더 물어보는 알림 메뉴

+
+

{description}

+
+
+ + +
+
+ ); +}; + +export default AlertBox; diff --git a/client/src/components/AuthInput/index.tsx b/client/src/components/AuthInput/index.tsx index d9258939..1646b5e0 100644 --- a/client/src/components/AuthInput/index.tsx +++ b/client/src/components/AuthInput/index.tsx @@ -2,6 +2,7 @@ import type { ChangeEventHandler, ComponentPropsWithRef, HTMLInputTypeAttribute, + FC, } from 'react'; import cn from 'classnames'; @@ -15,7 +16,7 @@ interface Props extends ComponentPropsWithRef<'input'> { placeholder?: string; } -const AuthInput: React.FC = forwardRef( +const AuthInput: FC = forwardRef( ( { type = 'text', diff --git a/client/src/components/Avatar/index.tsx b/client/src/components/Avatar/index.tsx index c339739d..ef8d30b1 100644 --- a/client/src/components/Avatar/index.tsx +++ b/client/src/components/Avatar/index.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, FC } from 'react'; -import React from 'react'; +import React, { memo } from 'react'; export interface AvatarProps { size: 'small' | 'medium'; @@ -34,9 +34,10 @@ const getFirstLetter = (str: string) => { return firstLetter; }; -const Avatar: React.FC = ({ +// TODO: url 바꿔야함 +const Avatar: FC = ({ name, - url, + url = 'https://cdn-icons-png.flaticon.com/512/1946/1946429.png', size, variant, className = '', @@ -48,10 +49,14 @@ const Avatar: React.FC = ({ > {children} {!children && - (url ? ( + (url.length ? ( {`${name}의 ) : ( @@ -61,4 +66,4 @@ const Avatar: React.FC = ({ ); }; -export default Avatar; +export default memo(Avatar); diff --git a/client/src/components/Badge/index.tsx b/client/src/components/Badge/index.tsx index 5efd918b..ff2f5c4a 100644 --- a/client/src/components/Badge/index.tsx +++ b/client/src/components/Badge/index.tsx @@ -1,3 +1,5 @@ +import type { FC } from 'react'; + import React from 'react'; interface BadgeProps { @@ -17,7 +19,7 @@ const background = { default: 'bg-label', }; -const Badge: React.FC = ({ +const Badge: FC = ({ children, size = 'small', color = 'default', diff --git a/client/src/components/Button/index.tsx b/client/src/components/Button/index.tsx index 2c00cd1c..9466887d 100644 --- a/client/src/components/Button/index.tsx +++ b/client/src/components/Button/index.tsx @@ -1,4 +1,4 @@ -import type { ReactNode, ComponentPropsWithoutRef } from 'react'; +import type { FC, ReactNode, ComponentPropsWithoutRef } from 'react'; import React, { useMemo } from 'react'; @@ -22,6 +22,12 @@ const buttonBg = (outlined: boolean) => ({ dark: outlined ? 'border-indigo hover:border-titleActive active:border-indigo' : 'bg-indigo hover:bg-titleActive active:bg-indigo border-indigo', + error: outlined + ? 'border-error hover:border-error-dark active:border-error' + : 'bg-error hover:bg-error-dark active:bg-error border-error', + success: outlined + ? 'border-success hover:border-success-dark active:border-success' + : 'bg-success hover:bg-success-dark active:bg-success border-success', }); const buttonText = (outlined: boolean) => ({ @@ -34,16 +40,29 @@ const buttonText = (outlined: boolean) => ({ dark: outlined ? 'text-indigo hover:text-titleActive active:text-indigo' : 'text-offWhite', + error: outlined + ? 'text-error hover:text-error-dark active:text-error' + : 'text-offWhite', + success: outlined + ? 'text-success hover:text-success-dark active:success' + : 'text-offWhite', }); const focusOutline = { primary: 'outline-primary-light', secondary: 'outline-secondary-light', dark: 'outline-placeholder', + error: 'outline-error-light', + success: 'outline-success-light', }; export type ButtonSize = 'md' | 'sm'; -export type ButtonColor = 'primary' | 'secondary' | 'dark'; +export type ButtonColor = + | 'primary' + | 'secondary' + | 'dark' + | 'error' + | 'success'; export interface Props extends ComponentPropsWithoutRef<'button'> { children: ReactNode; outlined?: boolean; @@ -51,10 +70,10 @@ export interface Props extends ComponentPropsWithoutRef<'button'> { minWidth?: number | string; maxWidth?: number | string; size?: ButtonSize; - color: ButtonColor; + color?: ButtonColor; } -const Button: React.FC = ({ +const Button: FC = ({ children, outlined = false, size = 'sm', diff --git a/client/src/components/ChannelContextMenu/index.tsx b/client/src/components/ChannelContextMenu/index.tsx new file mode 100644 index 00000000..33f78640 --- /dev/null +++ b/client/src/components/ChannelContextMenu/index.tsx @@ -0,0 +1,69 @@ +import type { JoinedChannel } from '@apis/channel'; +import type { FC } from 'react'; + +import { + ArrowRightOnRectangleIcon, + Cog6ToothIcon, + UserPlusIcon, +} from '@heroicons/react/20/solid'; +import { useRootStore } from '@stores/rootStore'; +import React from 'react'; + +export interface Props { + channel: JoinedChannel; +} + +const ChannelContextMenu: FC = ({ channel }) => { + const openCommonModal = useRootStore((state) => state.openCommonModal); + const closeContextMenuModal = useRootStore( + (state) => state.closeContextMenuModal, + ); + + const handleClickChannelInviteButton = () => {}; + + const handleClickChannelSettingsButton = () => {}; + + const handleClickChannelLeaveButton = () => {}; + + return ( +
+

채널 컨텍스트 메뉴

+
    + {channel.isPrivate && ( +
  • + +
  • + )} +
  • + +
  • +
+
+
+
+
+ +
+
+ ); +}; + +export default ChannelContextMenu; diff --git a/client/src/components/ChannelCreateBox/index.tsx b/client/src/components/ChannelCreateBox/index.tsx new file mode 100644 index 00000000..ce880378 --- /dev/null +++ b/client/src/components/ChannelCreateBox/index.tsx @@ -0,0 +1,210 @@ +import type { FC } from 'react'; + +import Button from '@components/Button'; +import CheckBox from '@components/CheckBox'; +import ErrorMessage from '@components/ErrorMessage'; +import Input from '@components/Input'; +import SuccessMessage from '@components/SuccessMessage'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import { HashtagIcon, LockClosedIcon } from '@heroicons/react/20/solid'; +import { useCreateChannelMutation, useSetChannelsQuery } from '@hooks/channel'; +import { useRootStore } from '@stores/rootStore'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +interface ChannelCreateFormFields { + channelName: string; + isPrivate: boolean; + channelDescription: string; + profileUrl: string; +} + +const channelCreateFormDefaultValue = { + channelName: '', + isPrivate: false, + channelDescription: '', + profileUrl: '', +}; + +export interface Props { + communityId: string; +} + +const ChannelCreateBox: FC = ({ communityId }) => { + const navigate = useNavigate(); + const closeCommonModal = useRootStore((state) => state.closeCommonModal); + + const { addChannelToCommunity } = useSetChannelsQuery(); + const createChannelMutation = useCreateChannelMutation({ + onSuccess: (createdChannel) => { + addChannelToCommunity(communityId, createdChannel); + closeCommonModal(); + navigate(`/communities/${communityId}/channels/${createdChannel._id}`); + }, + onError: (error) => { + defaultErrorHandler(error); + }, + }); + + const { control, handleSubmit, reset, watch } = + useForm({ + mode: 'all', + defaultValues: channelCreateFormDefaultValue, + }); + + const isPrivate = watch('isPrivate'); + + const handleCloseModal = () => { + closeCommonModal(); + reset(); + }; + + const handleSubmitChannelCreateForm = (fields: ChannelCreateFormFields) => { + const { + channelName: name, + channelDescription: description, + isPrivate: _isPrivate, + profileUrl, + } = fields; + + createChannelMutation.mutate({ + communityId, + name, + description, + isPrivate: _isPrivate, + profileUrl, + }); + }; + + return ( +
+
+
+

+ {isPrivate ? ( + <> + + 비공개 채널 만들기 + + ) : ( + <> + + 채널 만들기 + + )} +

+
+
+ + (value.length >= 2 && value.length <= 20) || + '채널 이름은 2자 이상, 20자 이하만 가능합니다!', + }, + }} + render={({ field, formState: { errors } }) => { + return ( +
+ +
+ {errors?.channelName ? ( + + {errors.channelName.message} + + ) : ( + field.value && ( + + 사용 가능한 이름입니다! + + ) + )} +
+
+ ); + }} + /> + + value.length <= 20 || '설명은 20자 이하만 가능합니다!', + }, + }} + render={({ field, formState: { errors } }) => { + return ( +
+ +
+ {errors?.channelDescription && ( + + {errors.channelDescription.message} + + )} +
+
+ ); + }} + /> + + { + return ( +
+ + 비공개 채널 +
+ ); + }} + /> +
+
+

액션 버튼 그룹

+ + +
+
+
+ ); +}; + +export default ChannelCreateBox; diff --git a/client/src/components/ChannelItem/index.tsx b/client/src/components/ChannelItem/index.tsx new file mode 100644 index 00000000..ae2f5311 --- /dev/null +++ b/client/src/components/ChannelItem/index.tsx @@ -0,0 +1,30 @@ +import type { JoinedChannel } from '@apis/channel'; +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import ChannelName from '@components/ChannelName'; +import React, { memo } from 'react'; +import { Link } from 'react-router-dom'; + +export interface Props extends ComponentPropsWithoutRef<'li'> { + communityId: string; + channel: JoinedChannel; +} + +const ChannelItem: FC = ({ channel, communityId, ...restProps }) => { + return ( +
  • + + + +
  • + ); +}; + +export default memo(ChannelItem); diff --git a/client/src/components/ChannelMetadata/index.tsx b/client/src/components/ChannelMetadata/index.tsx new file mode 100644 index 00000000..e3fa2fb5 --- /dev/null +++ b/client/src/components/ChannelMetadata/index.tsx @@ -0,0 +1,40 @@ +import type { Channel } from '@apis/channel'; +import type { FC } from 'react'; + +import ChannelName from '@components/ChannelName'; +import RoomMetadata from '@components/RoomMetadata'; +import { dateStringToKRLocaleDateString } from '@utils/date'; +import React, { memo } from 'react'; + +interface Props { + channel: Channel; + managerName?: string; +} + +const ChannelMetadata: FC = ({ + channel, + managerName = '(알수없음)', +}) => { + const { profileUrl, name: channelName, isPrivate, createdAt } = channel; + + return ( + +
    +
    + + 의 시작이에요. +
    +
    + @{managerName}님이 이 채널을{' '} + {dateStringToKRLocaleDateString(createdAt)}에 생성했습니다. +
    +
    +
    + ); +}; + +export default memo(ChannelMetadata); diff --git a/client/src/components/ChannelName/index.tsx b/client/src/components/ChannelName/index.tsx new file mode 100644 index 00000000..c835ea2a --- /dev/null +++ b/client/src/components/ChannelName/index.tsx @@ -0,0 +1,30 @@ +import type { FC, ComponentPropsWithoutRef } from 'react'; + +import { HashtagIcon, LockClosedIcon } from '@heroicons/react/20/solid'; +import React, { memo } from 'react'; + +export interface Props extends ComponentPropsWithoutRef<'div'> { + isPrivate: boolean; + name: string; +} + +const ChannelName: FC = ({ isPrivate, name, ...restProps }) => { + return ( +
    + {isPrivate ? ( +
    + 비공개 채널 + +
    + ) : ( +
    + 공개 채널 + +
    + )} +
    {name}
    +
    + ); +}; + +export default memo(ChannelName); diff --git a/client/src/components/ChatItem/index.tsx b/client/src/components/ChatItem/index.tsx new file mode 100644 index 00000000..2c41df38 --- /dev/null +++ b/client/src/components/ChatItem/index.tsx @@ -0,0 +1,69 @@ +import type { Chat } from '@apis/chat'; +import type { User } from '@apis/user'; +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import Avatar from '@components/Avatar'; +import { dateStringToKRLocaleDateString } from '@utils/date'; +import React, { memo } from 'react'; + +interface Props extends ComponentPropsWithoutRef<'li'> { + className?: string; + chat: Chat; + user?: User; + isSystem?: boolean; +} + +const ChatItem: FC = ({ + className = '', + chat, + user = { + profileUrl: undefined, + nickname: '(알수없음)', + }, + isSystem = false, +}) => { + const { content, createdAt } = chat; + + return ( +
  • +
    +
    + +
    +
    +
    + + {user.nickname} + + + {dateStringToKRLocaleDateString(createdAt, { + hour: 'numeric', + minute: 'numeric', + })} + + {chat?.deletedAt?.length ? ( + (삭제됨) + ) : chat?.updatedAt.length ? ( + chat.createdAt !== chat.updatedAt && ( + (수정됨) + ) + ) : ( + '' + )} +
    +
    {chat?.deletedAt ? '삭제된 메시지입니다' : content}
    +
    +
    +
  • + ); +}; + +export default memo(ChatItem); diff --git a/client/src/components/CheckBox/index.tsx b/client/src/components/CheckBox/index.tsx new file mode 100644 index 00000000..da3cbfd6 --- /dev/null +++ b/client/src/components/CheckBox/index.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef, FC } from 'react'; + +import { CheckCircleIcon } from '@heroicons/react/24/solid'; +import React from 'react'; + +interface Props extends ComponentPropsWithRef<'input'> {} + +const CheckBox: FC = ({ checked, ...restProps }) => { + return ( +
    + +
    + ); +}; + +export default CheckBox; diff --git a/client/src/components/CommunityContextMenu/index.tsx b/client/src/components/CommunityContextMenu/index.tsx new file mode 100644 index 00000000..7641a2cc --- /dev/null +++ b/client/src/components/CommunityContextMenu/index.tsx @@ -0,0 +1,91 @@ +import type { CommunitySummary } from '@apis/community'; +import type { FC } from 'react'; + +import CommunityInviteBox from '@components/CommunityInviteBox'; +import CommunityLeaveBox from '@components/CommunityLeaveBox'; +import { + UserPlusIcon, + Cog6ToothIcon, + ArrowRightOnRectangleIcon, +} from '@heroicons/react/20/solid'; +import { useCommunityUsersQuery } from '@hooks/user'; +import { useRootStore } from '@stores/rootStore'; +import React from 'react'; + +interface Props { + community: CommunitySummary; +} + +const CommunityContextMenu: FC = ({ community }) => { + const openCommonModal = useRootStore((state) => state.openCommonModal); + + useCommunityUsersQuery(community._id); + + const closeContextMenuModal = useRootStore( + (state) => state.closeContextMenuModal, + ); + + const handleClickCommunityLeaveButton = () => { + closeContextMenuModal(); + openCommonModal({ + content: , + overlayBackground: 'black', + x: '50%', + y: '50%', + transform: 'translate3d(-50%, -50%, 0)', + }); + }; + + const handleClickCommunitySettingsButton = () => {}; + + const handleClickUserInviteButton = () => { + closeContextMenuModal(); + openCommonModal({ + content: , + overlayBackground: 'black', + x: '50%', + y: '50%', + transform: 'translate3d(-50%, -50%, 0)', + }); + }; + + return ( +
    +

    커뮤니티 컨텍스트 메뉴

    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    +
    + +
    +
    + ); +}; + +export default CommunityContextMenu; diff --git a/client/src/components/CommunityCreateBox/index.tsx b/client/src/components/CommunityCreateBox/index.tsx new file mode 100644 index 00000000..760b190e --- /dev/null +++ b/client/src/components/CommunityCreateBox/index.tsx @@ -0,0 +1,168 @@ +import type { FC } from 'react'; + +import Button from '@components/Button'; +import ErrorMessage from '@components/ErrorMessage'; +import Input from '@components/Input'; +import SuccessMessage from '@components/SuccessMessage'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import { + useCommunitiesQuery, + useCreateCommunityMutation, +} from '@hooks/community'; +import { useRootStore } from '@stores/rootStore'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +interface CreateCommunityFormFields { + communityName: string; + communityDescription: string; +} + +const createCommunityFormDefaultValue = { + communityName: '', + communityDescription: '', +}; + +const CommunityCreateBox: FC = () => { + const navigate = useNavigate(); + const closeCommonModal = useRootStore((state) => state.closeCommonModal); + const { invalidateCommunitiesQuery } = useCommunitiesQuery(); + const createCommunityMutation = useCreateCommunityMutation({ + onSuccess: ({ _id }) => { + invalidateCommunitiesQuery() + .then(() => { + navigate(`/communities/${_id}`); + }) + .catch((error) => { + console.error(error); + toast.error('커뮤니티는 생성되었지만, 불러오는데 실패했습니다.'); + }); + + // eslint-disable-next-line no-use-before-define + handleCloseModal(); + }, + onError: (error) => { + defaultErrorHandler(error); + }, + }); + + const { control, handleSubmit, reset } = useForm({ + mode: 'all', + defaultValues: createCommunityFormDefaultValue, + }); + + const handleCloseModal = () => { + closeCommonModal(); + reset(); + }; + + const handleSubmitCreateCommunityForm = ( + fields: CreateCommunityFormFields, + ) => { + const { communityName: name, communityDescription: description } = fields; + + createCommunityMutation.mutate({ name, description }); + }; + + return ( +
    +
    +
    +

    커뮤니티 만들기

    +
    +
    + + (value.length >= 2 && value.length <= 20) || + '커뮤니티 이름은 2자 이상, 20자 이하만 가능합니다!', + }, + }} + render={({ field, formState: { errors } }) => { + return ( +
    + +
    + {errors?.communityName ? ( + + {errors.communityName.message} + + ) : ( + field.value && ( + + 사용 가능한 이름입니다! + + ) + )} +
    +
    + ); + }} + /> + + value.length <= 20 || '설명은 20자 이하만 가능합니다!', + }, + }} + render={({ field, formState: { errors } }) => { + return ( +
    + +
    + {errors?.communityDescription && ( + + {errors.communityDescription.message} + + )} +
    +
    + ); + }} + /> +
    +
    +

    액션 버튼 그룹

    + + +
    +
    +
    + ); +}; + +export default CommunityCreateBox; diff --git a/client/src/components/CommunityInviteBox/index.tsx b/client/src/components/CommunityInviteBox/index.tsx new file mode 100644 index 00000000..8fc380c5 --- /dev/null +++ b/client/src/components/CommunityInviteBox/index.tsx @@ -0,0 +1,72 @@ +import type { FC } from 'react'; + +import Button from '@components/Button'; +import CommunityInviteUserSearchResult from '@components/CommunityInviteUserSearchResult'; +import ErrorMessage from '@components/ErrorMessage'; +import SearchInput from '@components/SearchInput'; +import useUsersQuery from '@hooks/useUsersQuery'; +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +interface UserSearchInput { + filter: string; +} + +interface Props { + communityId: string; +} +/** + * 커뮤니티 초대 모달 컨텐츠 영역을 나타내는 컴포넌트입니다. + */ +const CommunityInviteBox: FC = ({ communityId }) => { + const { register, handleSubmit } = useForm(); + + const [submittedFilter, setSubmittedFilter] = useState(''); + const usersQuery = useUsersQuery(submittedFilter, { + enabled: !!submittedFilter, + }); + + const handleSubmitUserSearchForm = (data: UserSearchInput) => { + if (data.filter.trim()) { + setSubmittedFilter(data.filter); + } + }; + + return ( +
    +
    +
    +
    + + + +
    +
    + {usersQuery.isLoading && usersQuery.isFetching ? ( +
    로딩중...
    + ) : usersQuery.isLoading ? ( +
    검색어를 입력해주세요
    + ) : usersQuery.error ? ( + 에러가 발생했습니다. + ) : ( + + )} +
    +
    +
    + ); +}; + +export default CommunityInviteBox; diff --git a/client/src/components/CommunityInviteUserSearchResult/index.tsx b/client/src/components/CommunityInviteUserSearchResult/index.tsx new file mode 100644 index 00000000..0169bdf7 --- /dev/null +++ b/client/src/components/CommunityInviteUserSearchResult/index.tsx @@ -0,0 +1,89 @@ +import type { User, UserUID } from '@apis/user'; +import type { MouseEventHandler, FC } from 'react'; + +import UserItem from '@components/UserItem'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import { UserPlusIcon } from '@heroicons/react/20/solid'; +import { useInviteCommunityMutation } from '@hooks/community'; +import { + useCommunityUsersQuery, + useInvalidateCommunityUsersQuery, +} from '@hooks/user'; +import React from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; +import { toast } from 'react-toastify'; + +interface Props { + users: User[]; + communityId: string; +} + +const CommunityInviteUserSearchResult: FC = ({ users, communityId }) => { + const { communityUsersQuery } = useCommunityUsersQuery(communityId); + const inviteCommunityMutation = useInviteCommunityMutation(); + const { invalidateCommunityUsersQuery } = + useInvalidateCommunityUsersQuery(communityId); + + const handleClickCommunityInviteButton = + ( + _communityId: string, + userIds: Array, + ): MouseEventHandler => + () => { + if (inviteCommunityMutation.isLoading) return; + + inviteCommunityMutation + .mutateAsync({ communityId: _communityId, userIds }) + .then(() => { + invalidateCommunityUsersQuery().finally(() => { + toast.success('커뮤니티로 초대 성공!'); + }); + }) + .catch((_error) => defaultErrorHandler(_error)); + }; + + if (!users.length) { + return ( +
    + 검색 결과가 없습니다. +
    + ); + } + + return ( + +
      + {users.map((user) => { + /** 이미 커뮤니티에 포함된 사용자라면, true */ + const disabled = communityUsersQuery.data?.some( + ({ _id }) => _id === user._id, + ); + + return ( + + + + } + /> + ); + })} +
    +
    + ); +}; + +export default CommunityInviteUserSearchResult; diff --git a/client/src/components/CommunityLeaveBox/index.tsx b/client/src/components/CommunityLeaveBox/index.tsx new file mode 100644 index 00000000..c50cd3b5 --- /dev/null +++ b/client/src/components/CommunityLeaveBox/index.tsx @@ -0,0 +1,58 @@ +import type { CommunitySummary } from '@apis/community'; +import type { FC } from 'react'; + +import AlertBox from '@components/AlertBox'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import { + useLeaveCommunityMutation, + useSetCommunitiesQuery, +} from '@hooks/community'; +import { useRootStore } from '@stores/rootStore'; +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +interface Props { + community: CommunitySummary; +} + +const CommunityLeaveBox: FC = ({ community }) => { + const params = useParams(); + const navigate = useNavigate(); + const setCommunities = useSetCommunitiesQuery(); + const closeCommonModal = useRootStore((state) => state.closeCommonModal); + + const leaveCommunityMutation = useLeaveCommunityMutation({ + onSuccess: () => { + setCommunities((prevCommunities) => + prevCommunities?.filter( + (prevCommunity) => prevCommunity._id !== community._id, + ), + ); + + if (params?.communityId === community._id) { + navigate('/dms'); + } + closeCommonModal(); + }, + onError: (error) => { + defaultErrorHandler(error); + }, + }); + + const handleSubmitAlert = () => { + leaveCommunityMutation.mutate(community._id); + }; + + return ( +
    + +
    + ); +}; + +export default CommunityLeaveBox; diff --git a/client/src/components/DMMetadata/index.tsx b/client/src/components/DMMetadata/index.tsx new file mode 100644 index 00000000..b83fad12 --- /dev/null +++ b/client/src/components/DMMetadata/index.tsx @@ -0,0 +1,39 @@ +import type { FC } from 'react'; + +import ChannelItem from '@components/ChannelItem'; +import RoomMetadata from '@components/RoomMetadata'; +import { dateStringToKRLocaleDateString } from '@utils/date'; +import React from 'react'; + +interface Props { + friendProfileUrl?: string; + channelName: string; + isPrivate: boolean; + createdAt: string; + friendName: string; + creatorName: string; +} + +const DMMetadata: FC = ({ + friendProfileUrl, + friendName, + creatorName, + createdAt, +}) => { + return ( + +
    +
    + @{friendName}님과 나눈 다이렉트 + 메시지의 첫 부분이에요. +
    +
    + @{creatorName}님이 이 채널을{' '} + {dateStringToKRLocaleDateString(createdAt)}에 생성했습니다. +
    +
    +
    + ); +}; + +export default DMMetadata; diff --git a/client/src/components/DirectMessageUserItem/index.tsx b/client/src/components/DirectMessageUserItem/index.tsx new file mode 100644 index 00000000..b27e2597 --- /dev/null +++ b/client/src/components/DirectMessageUserItem/index.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; + +import UserProfile from '@components/UserProfile'; +import { useUserQuery } from '@hooks/user'; +import React from 'react'; + +interface Props { + userId: string; +} + +const DirectMessageUserItem: FC = ({ userId }) => { + const { userQuery } = useUserQuery(userId); + + if (userQuery.isLoading) return
    ; + return userQuery.data ? ( + + ) : ( +
    사용자가 존재하지 않습니다
    + ); +}; + +export default DirectMessageUserItem; diff --git a/client/src/components/ErrorMessage/index.tsx b/client/src/components/ErrorMessage/index.tsx index e3797115..9d460f0f 100644 --- a/client/src/components/ErrorMessage/index.tsx +++ b/client/src/components/ErrorMessage/index.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, FC } from 'react'; import React, { memo, useMemo } from 'react'; @@ -11,12 +11,15 @@ const messageSize = { export interface Props { children: ReactNode; size?: keyof typeof messageSize; + className?: string; } -const ErrorMessage: React.FC = ({ children, size = 'sm' }) => { +const ErrorMessage: FC = ({ children, size = 'sm', className = '' }) => { const memoizedSize = useMemo(() => messageSize[size], [size]); - return
    {children}
    ; + return ( +
    {children}
    + ); }; export default memo(ErrorMessage); diff --git a/client/src/components/FollowerUserItem/index.tsx b/client/src/components/FollowerUserItem/index.tsx index 7eefe8fd..0ca0cbb0 100644 --- a/client/src/components/FollowerUserItem/index.tsx +++ b/client/src/components/FollowerUserItem/index.tsx @@ -1,20 +1,31 @@ import type { User } from '@apis/user'; +import type { FC } from 'react'; import UserItem from '@components/UserItem'; import { EllipsisHorizontalIcon } from '@heroicons/react/20/solid'; -import React from 'react'; +import useFollowingMutation from '@hooks/useFollowingMutation'; +import React, { memo } from 'react'; interface Props { user: User; } -const FollowerUserItem: React.FC = ({ user }) => { +// TODO: 팔로잉한 사용자는 팔로우할 수 없도록 +// TODO: 팔로워, 팔로잉 정보 띄워주도록 +// TODO: 사용자 아이디 띄워주도록 +const FollowerUserItem: FC = ({ user }) => { + const followingMutation = useFollowingMutation(user._id); + return ( - @@ -24,4 +35,4 @@ const FollowerUserItem: React.FC = ({ user }) => { ); }; -export default FollowerUserItem; +export default memo(FollowerUserItem); diff --git a/client/src/components/FollowingUserItem/index.tsx b/client/src/components/FollowingUserItem/index.tsx index 038f8459..c8d766ba 100644 --- a/client/src/components/FollowingUserItem/index.tsx +++ b/client/src/components/FollowingUserItem/index.tsx @@ -1,4 +1,5 @@ import type { User } from '@apis/user'; +import type { FC } from 'react'; import UserItem from '@components/UserItem'; import { @@ -13,7 +14,7 @@ interface Props { user: User; } -const FollowingUserItem: React.FC = ({ user }) => { +const FollowingUserItem: FC = ({ user }) => { const followingMutation = useFollowingMutation(user._id); const { mutate: updateFollowing } = followingMutation; diff --git a/client/src/components/GnbItemContainer/index.tsx b/client/src/components/GnbItemContainer/index.tsx index 2f49c3c0..f147f895 100644 --- a/client/src/components/GnbItemContainer/index.tsx +++ b/client/src/components/GnbItemContainer/index.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, FC } from 'react'; import cn from 'classnames'; import React, { useState, memo, useCallback } from 'react'; @@ -11,7 +11,7 @@ interface Props { } // TODO: Tooltip 추가하기 -const GnbItemContainer: React.FC = ({ +const GnbItemContainer: FC = ({ children, disableLeftFillBar = false, isActive = false, diff --git a/client/src/components/Input/index.tsx b/client/src/components/Input/index.tsx new file mode 100644 index 00000000..f177ae39 --- /dev/null +++ b/client/src/components/Input/index.tsx @@ -0,0 +1,23 @@ +import type { ComponentPropsWithRef, FC } from 'react'; + +import React, { forwardRef } from 'react'; + +interface Props extends ComponentPropsWithRef<'input'> { + className?: string; +} + +const Input: FC = forwardRef(({ className, ...restProps }, ref) => { + return ( +
    + +
    + ); +}); + +Input.displayName = 'Input'; + +export default Input; diff --git a/client/src/components/Logo/index.tsx b/client/src/components/Logo/index.tsx index 16eb5be6..c8817a8c 100644 --- a/client/src/components/Logo/index.tsx +++ b/client/src/components/Logo/index.tsx @@ -1,3 +1,5 @@ +import type { FC } from 'react'; + import logoUrl from '@icons/logo.svg'; import React, { memo } from 'react'; @@ -13,7 +15,7 @@ export interface Props { size?: keyof typeof logoSize; } -const Logo: React.FC = ({ size = 'md' }) => { +const Logo: FC = ({ size = 'md' }) => { return (
    diff --git a/client/src/components/Modals/CommonModal/index.tsx b/client/src/components/Modals/CommonModal/index.tsx new file mode 100644 index 00000000..73ee181a --- /dev/null +++ b/client/src/components/Modals/CommonModal/index.tsx @@ -0,0 +1,65 @@ +import type { CSSProperties, FC } from 'react'; + +import { useRootStore } from '@stores/rootStore'; +import React, { useMemo } from 'react'; +import ReactModal from 'react-modal'; + +const OverlayBackground = { + black: 'rgba(0, 0, 0, 0.5)', + white: 'rgba(255, 255, 255, 0.5)', + transparent: 'transparent', +} as const; + +const CommonModal: FC = () => { + const { + isOpen, + content, + overlayBackground, + onCancel, + transform, + x = 1, + y = 1, + } = useRootStore((state) => state.commonModal); + const closeCommonModal = useRootStore((state) => state.closeCommonModal); + + const overlayStyle: CSSProperties = useMemo( + () => ({ + background: OverlayBackground[overlayBackground], + }), + [overlayBackground], + ); + + const contentStyle: CSSProperties = useMemo( + () => ({ + width: 'max-content', + height: 'max-content', + padding: 0, + border: 0, + top: y, + left: x, + transform, + }), + [x, y, transform], + ); + + return ( + { + if (!ref) return; + ref.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + }} + > + {content} + + ); +}; + +export default CommonModal; diff --git a/client/src/components/Modals/ContextMenuModal/index.tsx b/client/src/components/Modals/ContextMenuModal/index.tsx new file mode 100644 index 00000000..fe098cc9 --- /dev/null +++ b/client/src/components/Modals/ContextMenuModal/index.tsx @@ -0,0 +1,60 @@ +import type { CSSProperties, FC } from 'react'; + +import { useRootStore } from '@stores/rootStore'; +import React from 'react'; +import ReactModal from 'react-modal'; + +const modalOverlayStyle: CSSProperties = { + background: 'transparent', +}; + +interface Props {} + +ReactModal.setAppElement('#root'); + +const ContextMenuModal: FC = () => { + const { + x = 1, + y = 1, + isOpen, + content, + transform, + } = useRootStore((state) => state.contextMenuModal); + + const closeContextMenuModal = useRootStore( + (state) => state.closeContextMenuModal, + ); + + const modalContentStyle: CSSProperties = { + width: 'max-content', + height: 'max-content', + borderRadius: 10, + padding: 0, + left: x, + top: y, + transform, + }; + + return ( + { + if (!ref) return; + ref.addEventListener('mousedown', (e) => { + if (e.target === ref) { + closeContextMenuModal(); + } + }); + ref.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + }} + > + {content} + + ); +}; + +export default ContextMenuModal; diff --git a/client/src/components/Modals/CreateCommunityModal/index.tsx b/client/src/components/Modals/CreateCommunityModal/index.tsx deleted file mode 100644 index 75daa3d7..00000000 --- a/client/src/components/Modals/CreateCommunityModal/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactModal from 'react-modal'; - -interface Props {} - -const CreateCommunityModal: React.FC = () => { - return 커뮤니티 모달; -}; - -export default CreateCommunityModal; diff --git a/client/src/components/RoomMetadata/index.tsx b/client/src/components/RoomMetadata/index.tsx new file mode 100644 index 00000000..084b09bd --- /dev/null +++ b/client/src/components/RoomMetadata/index.tsx @@ -0,0 +1,33 @@ +import type { FC, ReactNode } from 'react'; + +import Avatar from '@components/Avatar'; +import { LOGO_IMG_URL } from '@constants/url'; +import React from 'react'; + +interface Props { + profileUrl?: string; + channelName: string; + children?: ReactNode; +} + +const RoomMetadata: FC = ({ + profileUrl = LOGO_IMG_URL, + children, + channelName, +}) => { + return ( +
    +
    + +
    + {children} +
    + ); +}; + +export default RoomMetadata; diff --git a/client/src/components/SearchInput/index.tsx b/client/src/components/SearchInput/index.tsx index ad655d8e..cade8835 100644 --- a/client/src/components/SearchInput/index.tsx +++ b/client/src/components/SearchInput/index.tsx @@ -1,34 +1,33 @@ -import type { InputHTMLAttributes } from 'react'; +import type { ComponentPropsWithRef, FC } from 'react'; import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'; -import React from 'react'; +import React, { forwardRef } from 'react'; -type SearchInputProps = InputHTMLAttributes; +export interface Props extends ComponentPropsWithRef<'input'> {} -const SearchInput: React.FC = ({ - className, - value, - onChange, - placeholder, - ...props -}) => { - return ( -
    -
    - 검색 - +const SearchInput: FC = forwardRef( + ({ className, value, onChange, placeholder, ...restProps }, ref) => { + return ( +
    +
    + 검색 + +
    +
    - -
    - ); -}; + ); + }, +); + +SearchInput.displayName = 'SearchInput'; export default SearchInput; diff --git a/client/src/components/SuccessMessage/index.tsx b/client/src/components/SuccessMessage/index.tsx index c73467ab..ebb8f2a4 100644 --- a/client/src/components/SuccessMessage/index.tsx +++ b/client/src/components/SuccessMessage/index.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, FC } from 'react'; import React, { memo, useMemo } from 'react'; @@ -13,7 +13,7 @@ export interface Props { size?: keyof typeof messageSize; } -const SuccessMessage: React.FC = ({ children, size = 'sm' }) => { +const SuccessMessage: FC = ({ children, size = 'sm' }) => { const memoizedSize = useMemo(() => messageSize[size], [size]); return
    {children}
    ; diff --git a/client/src/components/TextButton/index.tsx b/client/src/components/TextButton/index.tsx index bb088d60..d693171f 100644 --- a/client/src/components/TextButton/index.tsx +++ b/client/src/components/TextButton/index.tsx @@ -1,4 +1,9 @@ -import type { ReactNode, ComponentPropsWithoutRef, CSSProperties } from 'react'; +import type { + ReactNode, + ComponentPropsWithoutRef, + CSSProperties, + FC, +} from 'react'; import cn from 'classnames'; import React from 'react'; @@ -29,7 +34,7 @@ export interface Props extends ComponentPropsWithoutRef<'button'> { className?: string; } -const TextButton: React.FC = ({ +const TextButton: FC = ({ children, size, color = 'default', diff --git a/client/src/components/UserItem/index.tsx b/client/src/components/UserItem/index.tsx index 6d4d1d78..3f1a31cb 100644 --- a/client/src/components/UserItem/index.tsx +++ b/client/src/components/UserItem/index.tsx @@ -1,5 +1,5 @@ import type { User } from '@apis/user'; -import type { ComponentPropsWithoutRef, ReactNode } from 'react'; +import type { ComponentPropsWithoutRef, ReactNode, FC } from 'react'; import UserProfile from '@components/UserProfile'; import React, { memo } from 'react'; @@ -9,7 +9,7 @@ interface Props extends ComponentPropsWithoutRef<'li'> { right?: ReactNode; } -const UserItem: React.FC = ({ user, right }) => { +const UserItem: FC = ({ user, right }) => { return (
  • diff --git a/client/src/components/UserProfile/index.tsx b/client/src/components/UserProfile/index.tsx index 6c0c1446..2079f2b3 100644 --- a/client/src/components/UserProfile/index.tsx +++ b/client/src/components/UserProfile/index.tsx @@ -1,10 +1,10 @@ import type { User } from '@apis/user'; -import type { ComponentPropsWithoutRef } from 'react'; +import type { ComponentPropsWithoutRef, FC } from 'react'; import Avatar from '@components/Avatar'; import Badge from '@components/Badge'; import { USER_STATUS } from '@constants/user'; -import React from 'react'; +import React, { memo } from 'react'; interface Props extends ComponentPropsWithoutRef<'div'> { user: User; @@ -16,11 +16,9 @@ const STATUS_COLOR = { [USER_STATUS.AFK]: 'error', } as const; -const UserProfile: React.FC = ({ - user: { nickname, profileUrl, status }, -}) => { +const UserProfile: FC = ({ user: { nickname, profileUrl, status } }) => { return ( -
    +
    = ({ url={profileUrl} /> -
    {nickname}
    +
    + {nickname} +
    ); }; -export default UserProfile; +export default memo(UserProfile); diff --git a/client/src/components/UserSearchResult/index.tsx b/client/src/components/UserSearchResult/index.tsx new file mode 100644 index 00000000..eb6c7c48 --- /dev/null +++ b/client/src/components/UserSearchResult/index.tsx @@ -0,0 +1,31 @@ +import type { User } from '@apis/user'; +import type { FC } from 'react'; + +import FollowerUserItem from '@components/FollowerUserItem'; +import UserList from '@components/UserList'; +import React from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +export interface Props { + users: User[]; +} + +const UserSearchResult: FC = ({ users }) => { + return ( + + {users.length ? ( + + {users.map((user) => ( + + ))} + + ) : ( +
    + 검색된 사용자가 없습니다 +
    + )} +
    + ); +}; + +export default UserSearchResult; diff --git a/client/src/constants/endPoint.ts b/client/src/constants/endPoint.ts new file mode 100644 index 00000000..0af601aa --- /dev/null +++ b/client/src/constants/endPoint.ts @@ -0,0 +1,24 @@ +const endPoint = { + signUp: () => `/api/user/auth/signup` as const, + signIn: () => `/api/user/auth/signin` as const, + reissueToken: () => `/api/user/auth/refresh` as const, + createChannel: () => `/api/channel` as const, + getChannel: (channelId: string) => `/api/channels/${channelId}` as const, + getCommunities: () => `/api/communities` as const, + createCommunity: () => `/api/community` as const, + removeCommunity: (communityId: string) => + `/api/communities/${communityId}` as const, + leaveCommunity: (communityId: string) => + `/api/communities/${communityId}/me` as const, + inviteCommunity: (communityId: string) => + `/api/communities/${communityId}/users` as const, + getMyInfo: () => `/api/user/auth/me` as const, + getFollowings: () => `/api/user/followings` as const, + getFollowers: () => `/api/user/followers` as const, + toggleFollowing: (userId: string) => `/api/user/following/${userId}` as const, + getUsers: () => `/api/users`, // 사용할 때는 queryString 추가 전달 필요합니다 as const. + getCommunityUsers: (communityId: string) => + `/api/communities/${communityId}/users` as const, +} as const; + +export default endPoint; diff --git a/client/src/constants/user.ts b/client/src/constants/user.ts index bb4b268b..e2503316 100644 --- a/client/src/constants/user.ts +++ b/client/src/constants/user.ts @@ -1,5 +1,5 @@ export const USER_STATUS = { - OFFLINE: 'offline', - ONLINE: 'online', - AFK: 'afk', + OFFLINE: 'OFFLINE', + ONLINE: 'ONLINE', + AFK: 'AFK', } as const; diff --git a/client/src/hooks/channel.ts b/client/src/hooks/channel.ts new file mode 100644 index 00000000..5e9d6fe2 --- /dev/null +++ b/client/src/hooks/channel.ts @@ -0,0 +1,74 @@ +import type { + CreateChannelRequest, + CreateChannelResult, + GetChannelResult, + JoinedChannel, +} from '@apis/channel'; +import type { CommunitySummaries } from '@apis/community'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import type { AxiosError } from 'axios'; + +import { createChannel, getChannel } from '@apis/channel'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import queryKeyCreator from '@/queryKeyCreator'; + +export const useChannelQuery = (channelId: string) => { + const queryClient = useQueryClient(); + const key = queryKeyCreator.channel.detail(channelId); + + const query = useQuery(key, () => + getChannel(channelId), + ); + const invalidate = useCallback( + () => queryClient.invalidateQueries(key), + [queryClient, key], + ); + + return { channelQuery: query, invalidateChannelQuery: invalidate }; +}; + +export const useCreateChannelMutation = ( + options?: UseMutationOptions< + CreateChannelResult, + unknown, + CreateChannelRequest + >, +) => { + const key = queryKeyCreator.channel.createChannel(); + + const mutation = useMutation(key, createChannel, { ...options }); + + return mutation; +}; + +/** + * ### invalidate queries 하지 않고, 수동으로 queryClient update 할 때 사용합니다. + * - addChannelToCommunity: 커뮤니티 아이디와 추가할 채널을 인자로 전달. + */ +export const useSetChannelsQuery = () => { + const queryClient = useQueryClient(); + const key = queryKeyCreator.community.all(); + + // TODO: 네이밍 + const addChannelToCommunity = ( + communityId: string, + channel: JoinedChannel, + ) => { + queryClient.setQueryData(key, (communities) => { + const newCommunities = communities?.map((community) => { + if (community._id !== communityId) return community; + + return { + ...community, + channels: [...community.channels, channel], + }; + }); + + return newCommunities; + }); + }; + + return { addChannelToCommunity }; +}; diff --git a/client/src/hooks/chat.ts b/client/src/hooks/chat.ts new file mode 100644 index 00000000..e9978a4f --- /dev/null +++ b/client/src/hooks/chat.ts @@ -0,0 +1,20 @@ +import type { GetChatsResult } from '@apis/chat'; +import type { AxiosError } from 'axios'; + +import { getChats } from '@apis/chat'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +export const useChatsInfiniteQuery = (channelId: string) => { + const key = queryKeyCreator.chat.list(channelId); + const infiniteQuery = useInfiniteQuery( + key, + ({ pageParam = -1 }) => getChats(channelId, pageParam), + { + getPreviousPageParam: (firstPage) => firstPage.prev, + }, + ); + + return infiniteQuery; +}; diff --git a/client/src/hooks/community.ts b/client/src/hooks/community.ts new file mode 100644 index 00000000..3dcd29fe --- /dev/null +++ b/client/src/hooks/community.ts @@ -0,0 +1,129 @@ +import type { JoinedChannel } from '@apis/channel'; +import type { + CreateCommunityResult, + CreateCommunityRequest, + RemoveCommunityResult, + LeaveCommunityResult, + InviteCommunityResult, + CommunitySummaries, +} from '@apis/community'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import type { AxiosError } from 'axios'; + +import { + inviteCommunity, + createCommunity, + getCommunities, + leaveCommunity, + removeCommunity, +} from '@apis/community'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import queryKeyCreator from 'src/queryKeyCreator'; + +export const useCommunitiesQuery = () => { + const queryClient = useQueryClient(); + + const key = queryKeyCreator.community.all(); + const query = useQuery(key, getCommunities); + const invalidate = useCallback(() => { + return queryClient.invalidateQueries(key); + }, [queryClient, key]); + + return { communitiesQuery: query, invalidateCommunitiesQuery: invalidate }; +}; + +/** + * @param id 접속한 커뮤니티의 id + * 접속한 커뮤니티에서 참여하고 있는 채널 목록 + */ +export const useJoinedChannelsQuery = (id: string) => { + const key = queryKeyCreator.community.all(); + const query = useQuery( + key, + getCommunities, + { + select: (data) => { + return data.find((community) => community._id === id)?.channels || []; + }, + }, + ); + + return { joinedChannelsQuery: query }; +}; + +interface SetCommunities { + ( + callback: ( + communities?: CommunitySummaries, + ) => CommunitySummaries | undefined, + ): void; + (communities: CommunitySummaries): void; +} + +/** + * ### 커뮤니티 쿼리 응답 데이터의 Setter를 반환하는 Custom Hook + * - useState hook의 setState처럼 사용하면 됩니다. + * ```tsx + * 사용 예시 + * setComms( + * (prevComms) => prevComms.filter( + * (prevComm) => prevComm.id !== id + * ) + * ) + * ``` + */ +export const useSetCommunitiesQuery = () => { + const queryClient = useQueryClient(); + + const key = queryKeyCreator.community.all(); + + const setCommunities: SetCommunities = (cb) => { + queryClient.setQueryData(key, (communities) => { + if (typeof cb === 'function') return cb(communities); + return cb; + }); + }; + + return setCommunities; +}; + +export const useCreateCommunityMutation = ( + options: UseMutationOptions< + CreateCommunityResult, + unknown, + CreateCommunityRequest + >, +) => { + const key = queryKeyCreator.community.createCommunity(); + const mutation = useMutation(key, createCommunity, { ...options }); + + return mutation; +}; + +export const useRemoveCommunityMutation = ( + options?: UseMutationOptions, +) => { + const key = queryKeyCreator.community.removeCommunity(); + const mutation = useMutation(key, removeCommunity, { ...options }); + + return mutation; +}; + +export const useLeaveCommunityMutation = ( + options?: UseMutationOptions, +) => { + const key = queryKeyCreator.community.leaveCommunity(); + const mutation = useMutation(key, leaveCommunity, { ...options }); + + return mutation; +}; + +export const useInviteCommunityMutation = ( + options?: UseMutationOptions, +) => { + const key = queryKeyCreator.community.inviteCommunity(); + const mutation = useMutation(key, inviteCommunity, { ...options }); + + return mutation; +}; diff --git a/client/src/hooks/useDirectMessagesQuery.ts b/client/src/hooks/useDirectMessagesQuery.ts index 474f4ac5..2d7d78a4 100644 --- a/client/src/hooks/useDirectMessagesQuery.ts +++ b/client/src/hooks/useDirectMessagesQuery.ts @@ -7,8 +7,10 @@ import { useQuery } from '@tanstack/react-query'; import queryKeyCreator from '@/queryKeyCreator'; const useDirectMessagesQuery = () => { + const key = queryKeyCreator.directMessage.list(); + const query = useQuery( - queryKeyCreator.directMessage.list(), + key, getDirectMessages, ); diff --git a/client/src/hooks/useFollowersQuery.ts b/client/src/hooks/useFollowersQuery.ts index a3ff8668..77872840 100644 --- a/client/src/hooks/useFollowersQuery.ts +++ b/client/src/hooks/useFollowersQuery.ts @@ -1,33 +1,21 @@ -import type { GetFollowersResponse, GetFollowersResult } from '@apis/user'; +import type { Followers } from '@apis/user'; +import type { AxiosError } from 'axios'; 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, { + const query = useQuery(key, getFollowers, { ...options, - select: (data) => { - const { statusCode, result } = data; - const followers = filter - ? result.followers.filter(({ nickname }) => + select: (data) => + filter + ? data.filter(({ nickname }) => nickname.toUpperCase().includes(filter.toUpperCase()), ) - : result.followers; - - return { statusCode, ...result, followers }; - }, + : data, }); return query; diff --git a/client/src/hooks/useFollowingMutation.ts b/client/src/hooks/useFollowingMutation.ts index 72f5cacb..12621c6b 100644 --- a/client/src/hooks/useFollowingMutation.ts +++ b/client/src/hooks/useFollowingMutation.ts @@ -1,4 +1,4 @@ -import type { GetFollowingsResponse, User } from '@apis/user'; +import type { User, Followings } from '@apis/user'; import { updateFollowing } from '@apis/user'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -12,28 +12,21 @@ const useFollowingMutation = (userId: string) => { onMutate: async (deleted: User) => { await queryClient.cancelQueries(key); - const previousFollowings = - queryClient.getQueryData(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, - ), - }, - }); + queryClient.setQueryData( + key, + previousFollowings.filter( + (following) => following._id !== deleted._id, + ), + ); } return { previousFollowings }; }, onError: (err, variables, context) => { if (context?.previousFollowings) - queryClient.setQueryData( - key, - context.previousFollowings, - ); + queryClient.setQueryData(key, context.previousFollowings); }, onSettled: () => { queryClient.invalidateQueries(key); diff --git a/client/src/hooks/useFollowingsQuery.ts b/client/src/hooks/useFollowingsQuery.ts index 1d6cd0af..018622cb 100644 --- a/client/src/hooks/useFollowingsQuery.ts +++ b/client/src/hooks/useFollowingsQuery.ts @@ -1,36 +1,24 @@ -import type { GetFollowingsResponse, GetFollowingsResult } from '@apis/user'; +import type { Followings } from '@apis/user'; +import type { AxiosError } from 'axios'; 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, { + const query = useQuery(key, getFollowings, { ...options, - select: (data) => { - const { statusCode, result } = data; - const followings = filter - ? result.followings.filter(({ nickname }) => + select: (data) => + filter + ? data.filter(({ nickname }) => nickname.toUpperCase().includes(filter.toUpperCase()), ) - : result.followings; - - return { statusCode, ...result, followings }; - }, + : data, }); return query; diff --git a/client/src/hooks/useIsIntersecting.ts b/client/src/hooks/useIsIntersecting.ts new file mode 100644 index 00000000..11ab00ef --- /dev/null +++ b/client/src/hooks/useIsIntersecting.ts @@ -0,0 +1,28 @@ +import type { RefObject } from 'react'; + +import React, { useEffect, useRef, useState } from 'react'; + +export const useIsIntersecting = ( + targetRef: RefObject, +) => { + const observerRef = useRef(null); + const [isIntersecting, setIsIntersecting] = useState(false); + + useEffect(() => { + if (!targetRef.current) return; + + if (!observerRef.current) { + observerRef.current = new IntersectionObserver((entries) => + setIsIntersecting(entries.some((entry) => entry.isIntersecting)), + ); + } + + observerRef.current.observe(targetRef.current); + + /* eslint-disable consistent-return */ + return () => observerRef?.current?.disconnect(); + }, [targetRef.current]); + return isIntersecting; +}; + +export default useIsIntersecting; diff --git a/client/src/hooks/useMyInfoQuery.ts b/client/src/hooks/useMyInfoQuery.ts index f8c6d976..eaeeacdd 100644 --- a/client/src/hooks/useMyInfoQuery.ts +++ b/client/src/hooks/useMyInfoQuery.ts @@ -1,4 +1,4 @@ -import type { MyInfoResult } from '@apis/user'; +import type { GetMyInfoResult } from '@apis/user'; import type { AxiosError } from 'axios'; import { getMyInfo } from '@apis/user'; @@ -7,7 +7,7 @@ import queryKeyCreator from 'src/queryKeyCreator'; export const useMyInfoQuery = () => { const key = queryKeyCreator.me(); - const query = useQuery(key, getMyInfo, {}); + const query = useQuery(key, getMyInfo); return query; }; @@ -17,7 +17,7 @@ export default useMyInfoQuery; export const useMyInfo = () => { const queryClient = useQueryClient(); const key = queryKeyCreator.me(); - const me = queryClient.getQueryData(key); + const me = queryClient.getQueryData(key); return me; }; diff --git a/client/src/hooks/useReissueTokenMutation.ts b/client/src/hooks/useReissueTokenMutation.ts index d30af9c3..e0c55d31 100644 --- a/client/src/hooks/useReissueTokenMutation.ts +++ b/client/src/hooks/useReissueTokenMutation.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse, SuccessResponse } from '@@types/apis/response'; +import type { ErrorResponse } from '@@types/apis/response'; import type { ReissueTokenResult } from '@apis/auth'; import type { UseMutationResult } from '@tanstack/react-query'; @@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom'; import queryKeyCreator from '@/queryKeyCreator'; type UseReissueTokenMutationResult = UseMutationResult< - SuccessResponse, + ReissueTokenResult, unknown, void, unknown @@ -36,7 +36,7 @@ const useReissueTokenMutation: UseReissueTokenMutation = ( const key = queryKeyCreator.reissueToken(); const mutation = useMutation(key, reissueToken, { onSuccess: (data) => { - setAccessToken(data.result.accessToken); + setAccessToken(data.accessToken); }, onError: (error) => { if (!(error instanceof AxiosError)) { diff --git a/client/src/hooks/useSignInMutation.ts b/client/src/hooks/useSignInMutation.ts index c8b118f4..73e5c718 100644 --- a/client/src/hooks/useSignInMutation.ts +++ b/client/src/hooks/useSignInMutation.ts @@ -1,4 +1,3 @@ -import type { SuccessResponse } from '@@types/apis/response'; import type { SignInRequest, SignInResult } from '@apis/auth'; import type { UseMutationOptions } from '@tanstack/react-query'; @@ -8,11 +7,7 @@ import { useMutation } from '@tanstack/react-query'; import queryKeyCreator from '@/queryKeyCreator'; const useSignInMutation = ( - options: UseMutationOptions< - SuccessResponse, - unknown, - SignInRequest - >, + options: UseMutationOptions, ) => { const key = queryKeyCreator.signIn(); const mutation = useMutation(key, signIn, { diff --git a/client/src/hooks/useSignUpMutation.ts b/client/src/hooks/useSignUpMutation.ts index eceb2fef..097d76b9 100644 --- a/client/src/hooks/useSignUpMutation.ts +++ b/client/src/hooks/useSignUpMutation.ts @@ -1,4 +1,3 @@ -import type { SuccessResponse } from '@@types/apis/response'; import type { SignUpRequest, SignUpResult } from '@apis/auth'; import type { UseMutationOptions } from '@tanstack/react-query'; @@ -8,11 +7,7 @@ import { useMutation } from '@tanstack/react-query'; import queryKeyCreator from '@/queryKeyCreator'; const useSignUpMutation = ( - options: UseMutationOptions< - SuccessResponse, - unknown, - SignUpRequest - >, + options: UseMutationOptions, ) => { const key = queryKeyCreator.signUp(); const mutation = useMutation(key, signUp, { diff --git a/client/src/hooks/useUsersQuery.ts b/client/src/hooks/useUsersQuery.ts index 7f375c8b..bb49221f 100644 --- a/client/src/hooks/useUsersQuery.ts +++ b/client/src/hooks/useUsersQuery.ts @@ -1,30 +1,22 @@ -import type { GetUsersResponse, GetUsersResult } from '@apis/user'; +import type { GetUsersResult } from '@apis/user'; +import type { AxiosError } from 'axios'; -import { GetUsers } 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( + const query = useQuery( key, - () => GetUsers({ search: filter }), + () => getUsers({ search: filter }), { ...options, - select: (data) => { - const { statusCode, result } = data; - const { users } = result; - - return { statusCode, ...result, users }; - }, + refetchOnWindowFocus: false, }, ); diff --git a/client/src/hooks/user.ts b/client/src/hooks/user.ts new file mode 100644 index 00000000..1a8cfa53 --- /dev/null +++ b/client/src/hooks/user.ts @@ -0,0 +1,27 @@ +import type { User } from '@apis/user'; +import type { AxiosError } from 'axios'; + +import { getCommunityUsers } from '@apis/user'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +export const useCommunityUsersQuery = (communityId: string) => { + const key = queryKeyCreator.user.communityUsers(communityId); + + const query = useQuery(key, () => + getCommunityUsers(communityId), + ); + + return { communityUsersQuery: query }; +}; + +export const useInvalidateCommunityUsersQuery = (communityId: string) => { + const queryClient = useQueryClient(); + const key = queryKeyCreator.user.communityUsers(communityId); + + const invalidateCommunityUsersQuery = () => + queryClient.invalidateQueries(key); + + return { invalidateCommunityUsersQuery }; +}; diff --git a/client/src/index.tsx b/client/src/index.tsx index ac339abd..8c32843a 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -8,11 +8,11 @@ import { injectStyle } from 'react-toastify/dist/inject-style'; import App from './App'; import './index.css'; -if (process.env.NODE_ENV === 'development') { - const { worker } = require('./mocks/browsers'); - - worker.start(); -} +// if (process.env.NODE_ENV === 'development') { +// const { worker } = require('./mocks/browsers'); +// +// worker.start(); +// } const queryClient = new QueryClient(); const root = ReactDOM.createRoot( diff --git a/client/src/layouts/CommunityNav/index.tsx b/client/src/layouts/CommunityNav/index.tsx index 2a66c10d..e4fe8745 100644 --- a/client/src/layouts/CommunityNav/index.tsx +++ b/client/src/layouts/CommunityNav/index.tsx @@ -1,14 +1,115 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; +import type { JoinedChannel } from '@apis/channel'; +import type { MouseEventHandler } from 'react'; + +import ChannelContextMenu from '@components/ChannelContextMenu'; +import ChannelCreateBox from '@components/ChannelCreateBox'; +import ChannelItem from '@components/ChannelItem'; +import ErrorMessage from '@components/ErrorMessage'; +import { ChevronDownIcon, PlusIcon } from '@heroicons/react/20/solid'; +import { useCommunitiesQuery, useJoinedChannelsQuery } from '@hooks/community'; +import { useRootStore } from '@stores/rootStore'; +import cn from 'classnames'; +import React, { useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; const CommunityNav = () => { - const { communityId } = useParams(); + const params = useParams() as { communityId: string; roomId?: string }; + const { communityId, roomId } = params; + const { communitiesQuery } = useCommunitiesQuery(); + const communitySummary = communitiesQuery.data?.find( + ({ _id }) => _id === communityId, + ); + const { joinedChannelsQuery } = useJoinedChannelsQuery(communityId); + const joinedChannelsLength = joinedChannelsQuery.data?.length || 0; + + const [visible, setVisible] = useState(true); + + const toggleVisible = () => setVisible((prevVisible) => !prevVisible); + const rotateChevronIconClassnames = cn({ 'rotate-[-90deg]': !visible }); + const openContextMenuModal = useRootStore( + (state) => state.openContextMenuModal, + ); + const openCommonModal = useRootStore((state) => state.openCommonModal); + + const handleRightClickChannelItem = + (channel: JoinedChannel): MouseEventHandler => + (e) => { + openContextMenuModal({ + x: e.clientX, + y: e.clientY, + content: , + }); + }; + + const handleClickChannelCreateButton = () => { + openCommonModal({ + x: '50%', + y: '50%', + transform: 'translate3d(-50%, -50%, 0)', + overlayBackground: 'black', + content: , + }); + }; return ( -