From ab3d361ad737c95324a802ec41f459d6f1339bcd Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Sun, 27 Jul 2025 21:59:22 +0900 Subject: [PATCH 01/12] =?UTF-8?q?FLOW-35:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20context=20=EB=A9=94=EB=89=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=A6=AC?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=ED=8C=80=20=EC=A0=95=EB=B3=B4=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/store.ts | 12 +- src/service/feature/auth/api/profileApi.ts | 11 +- src/service/feature/auth/context/useAuth.ts | 10 +- .../feature/auth/hook/auth/useLogin.ts | 28 ++- .../feature/auth/schema/profileSchema.ts | 11 + .../feature/auth/store/auth/authSlice.ts | 11 +- src/service/feature/auth/types/profile.ts | 3 +- .../feature/channel/api/categorieAPI.ts | 16 +- src/service/feature/channel/api/channelAPI.ts | 98 ++++----- .../hook/mutation/useCategoryMutation.ts | 15 ++ .../hook/mutation/useChannelMutation.ts | 70 ++---- .../feature/channel/schema/channelSchema.ts | 21 ++ src/service/feature/channel/types/category.ts | 15 ++ src/service/feature/channel/types/channel.ts | 73 +++---- .../feature/chat/context/SocketContext.ts | 2 +- src/service/feature/chat/type/messages.ts | 7 +- .../feature/common/axios/axiosInstance.ts | 3 +- .../feature/common/hooks/useApiMutation.ts | 34 +++ src/service/feature/member/types/memberAPI.ts | 12 +- src/service/feature/team/store/teamSlice.ts | 36 ++++ src/view/layout/profile/UserProfileBar.tsx | 28 ++- .../profile/component/EditProfileModal.tsx | 84 ++++++++ .../profile/component/ProfileContextMenu.tsx | 58 +++++ .../profile/component/UpdateStatusModal.tsx | 50 +++++ .../components/channel/ChannelCategory.tsx | 23 +- .../components/channel/ChannelDialog.tsx | 200 ++++++++++++++++++ .../pages/auth/login/components/LoginForm.tsx | 4 +- src/view/pages/chat/ChatPage.tsx | 5 +- .../pages/chat/components/input/ChatInput.tsx | 137 ++++++++++++ .../chat/components/input/MemberDropdown.tsx | 62 ++++++ .../chat/components/layout/ChatInput.tsx | 117 ---------- .../components/layout/ChatMemberDialog.tsx | 2 +- .../pages/chat/components/layout/ChatView.tsx | 5 +- .../components/message/ChatMessageItem.tsx | 2 +- 34 files changed, 941 insertions(+), 324 deletions(-) create mode 100644 src/service/feature/auth/schema/profileSchema.ts create mode 100644 src/service/feature/channel/hook/mutation/useCategoryMutation.ts create mode 100644 src/service/feature/channel/schema/channelSchema.ts create mode 100644 src/service/feature/channel/types/category.ts create mode 100644 src/service/feature/common/hooks/useApiMutation.ts create mode 100644 src/service/feature/team/store/teamSlice.ts create mode 100644 src/view/layout/profile/component/EditProfileModal.tsx create mode 100644 src/view/layout/profile/component/ProfileContextMenu.tsx create mode 100644 src/view/layout/profile/component/UpdateStatusModal.tsx create mode 100644 src/view/layout/sidebar/components/channel/ChannelDialog.tsx create mode 100644 src/view/pages/chat/components/input/ChatInput.tsx create mode 100644 src/view/pages/chat/components/input/MemberDropdown.tsx delete mode 100644 src/view/pages/chat/components/layout/ChatInput.tsx diff --git a/src/app/store.ts b/src/app/store.ts index 03488ff..77ea474 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,12 +1,13 @@ import { configureStore } from '@reduxjs/toolkit'; import { persistStore, persistReducer } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; -import { authReducer } from '@service/feature/auth/store/auth/authSlice.ts'; +import { authReducer } from '@service/feature/auth/store/auth/authSlice'; +import teamReducer from '@service/feature/team/store/teamSlice'; const persistConfig = { key: 'auth', storage, - whitelist: ['user', 'isAuthenticated'], + whitelist: ['user', 'isAuthenticated', 'profile'], }; const persistedAuthReducer = persistReducer(persistConfig, authReducer); @@ -14,12 +15,13 @@ const persistedAuthReducer = persistReducer(persistConfig, authReducer); export const store = configureStore({ reducer: { auth: persistedAuthReducer, + teams: teamReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }), }); -export const persistor = persistStore(store); - export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; \ No newline at end of file +export type AppDispatch = typeof store.dispatch; + +export const persistor = persistStore(store); \ No newline at end of file diff --git a/src/service/feature/auth/api/profileApi.ts b/src/service/feature/auth/api/profileApi.ts index 622d550..f30bc81 100644 --- a/src/service/feature/auth/api/profileApi.ts +++ b/src/service/feature/auth/api/profileApi.ts @@ -1,10 +1,19 @@ import createAxiosInstance from '@service/feature/common/axios/axiosInstance.ts'; -import { UserProfile } from '@service/feature/auth/types/profile.ts'; +import {MemberState, UserProfile} from '@service/feature/auth/types/profile.ts'; import { ApiResponse } from '@service/feature/common/axios/apiType.ts'; +import {UpdateProfileRequest} from "@service/feature/auth/schema/profileSchema.ts"; const axios = createAxiosInstance(); export const getProfile = async (): Promise => { const response = await axios.get>('/members'); return response.data.data; +}; + +export const updateStatus = async (memberState: MemberState): Promise => { + await axios.patch('/members/status', {memberState}); +}; + +export const updateProfile = async (profileData: UpdateProfileRequest): Promise => { + await axios.put('/members', profileData); }; \ No newline at end of file diff --git a/src/service/feature/auth/context/useAuth.ts b/src/service/feature/auth/context/useAuth.ts index cd7df77..1b44bc0 100644 --- a/src/service/feature/auth/context/useAuth.ts +++ b/src/service/feature/auth/context/useAuth.ts @@ -1,8 +1,8 @@ import { useContext } from 'react'; -import { AuthContext } from './AuthContext'; +import { AuthContext, AuthContextType } from './AuthContext'; -export const useAuth = () => { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be used within AuthProvider'); - return ctx; +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + return context; }; \ No newline at end of file diff --git a/src/service/feature/auth/hook/auth/useLogin.ts b/src/service/feature/auth/hook/auth/useLogin.ts index 017b00c..a8e8b7a 100644 --- a/src/service/feature/auth/hook/auth/useLogin.ts +++ b/src/service/feature/auth/hook/auth/useLogin.ts @@ -1,10 +1,12 @@ import { useMutation } from '@tanstack/react-query'; import { login } from '../../api/authApi'; import { useDispatch } from 'react-redux'; -import { setUser } from '@service/feature/auth'; +import {logout, setUser} from '@service/feature/auth'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { loginSchema } from '../../schema/authSchema.ts'; +import {getProfile} from "@service/feature/auth/api/profileApi.ts"; +import {setProfile} from "@service/feature/auth/store/profile/userSlice.ts"; export const useLogin = () => { const dispatch = useDispatch(); @@ -21,7 +23,7 @@ export const useLogin = () => { } return login(result.data); }, - onSuccess: (data) => { + onSuccess: async (data) => { if (!data.id || !data.token) { toast.error('로그인에 성공했지만 사용자 정보가 올바르지 않습니다.'); return; @@ -32,11 +34,19 @@ export const useLogin = () => { dispatch( setUser({ userId: data.id, - nickname: data.name, email: '', + name: data.name, }), ); + try { + const profile = await getProfile(); + dispatch(setProfile(profile)); + } catch (error) { + console.error('프로필 정보를 불러오지 못했습니다:', error); + toast.error('프로필 정보를 업데이트하지 못했습니다.'); + } + toast.success('로그인 성공!'); navigate('/channels/@me'); }, @@ -47,3 +57,15 @@ export const useLogin = () => { }, }); }; + +export const useLogout = () => { + const dispatch = useDispatch(); + + return () => { + dispatch(logout()); + toast.success('로그아웃 되었습니다!'); + document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;'; + window.location.href = '/'; + }; +}; + diff --git a/src/service/feature/auth/schema/profileSchema.ts b/src/service/feature/auth/schema/profileSchema.ts new file mode 100644 index 0000000..9559e3f --- /dev/null +++ b/src/service/feature/auth/schema/profileSchema.ts @@ -0,0 +1,11 @@ +import {z} from 'zod'; + +export const updateProfileSchema = z.object({ + birth: z.string(), + name: z.string(), + newPassword: z.string().optional(), + password: z.string(), + avatarUrl: z.string().optional() +}); + +export type UpdateProfileRequest = z.infer; \ No newline at end of file diff --git a/src/service/feature/auth/store/auth/authSlice.ts b/src/service/feature/auth/store/auth/authSlice.ts index fb4034f..b38f890 100644 --- a/src/service/feature/auth/store/auth/authSlice.ts +++ b/src/service/feature/auth/store/auth/authSlice.ts @@ -1,18 +1,21 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import {UserProfile} from "@service/feature/auth/types/profile.ts"; interface User { userId: string; email: string; - nickname: string; + name: string; } interface AuthState { user: User | null; + profile: UserProfile | null; isAuthenticated: boolean; } const initialState: AuthState = { user: null, + profile: null, isAuthenticated: false, }; @@ -24,8 +27,12 @@ const authSlice = createSlice({ state.user = action.payload; state.isAuthenticated = true; }, + setProfile(state, action: PayloadAction) { + state.profile = action.payload; + }, logout: (state) => { state.user = null; + state.profile = null; state.isAuthenticated = false; }, }, @@ -34,4 +41,4 @@ const authSlice = createSlice({ export const authReducer = authSlice.reducer; export const authActions = authSlice.actions; -export const { setUser, logout } = authSlice.actions; \ No newline at end of file +export const { setUser, setProfile, logout } = authSlice.actions; \ No newline at end of file diff --git a/src/service/feature/auth/types/profile.ts b/src/service/feature/auth/types/profile.ts index bf2db63..29eb6d4 100644 --- a/src/service/feature/auth/types/profile.ts +++ b/src/service/feature/auth/types/profile.ts @@ -1,4 +1,5 @@ export type MemberState = 'ONLINE' | 'OFFLINE' | 'IDLE' | 'DO_NOT_DISTURB'; +export type MemberType = 'MEMBER' | 'ADMIN' export interface UserProfile { id: string; @@ -6,7 +7,7 @@ export interface UserProfile { nickname: string; name: string; birth: string; - type: 'MEMBER' | 'ADMIN' | string; + type: MemberType; avatarUrl: string | null; state: MemberState; createdAt: string; diff --git a/src/service/feature/channel/api/categorieAPI.ts b/src/service/feature/channel/api/categorieAPI.ts index f09c96c..1822f6b 100644 --- a/src/service/feature/channel/api/categorieAPI.ts +++ b/src/service/feature/channel/api/categorieAPI.ts @@ -1,10 +1,11 @@ import { createAxiosInstance } from '@service/feature/common/axios/axiosInstance'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import {CreateCategoryResponse} from "@service/feature/channel/types/category.ts"; const axios = createAxiosInstance(); -export const createCategory = async (teamId: string, name: string) => { +export const createCategory = async (teamId: string, name: string): Promise => { const res = await axios.post(`/teams/${teamId}/categories`, { name }); return res.data; }; @@ -20,17 +21,4 @@ export const moveCategory = async (teamId: string, body: { }) => { const res = await axios.patch(`/teams/${teamId}/categories/${body.prevCategoryId}`, body); return res.data; -}; - - -export const useCreateCategoryMutation = (teamId: string) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (name: string) => createCategory(teamId, name), - onSuccess: () => { - toast.success('카테고리 생성 완료!'); - queryClient.invalidateQueries({ queryKey: ['teamStructure', teamId] }); - }, - }); }; \ No newline at end of file diff --git a/src/service/feature/channel/api/channelAPI.ts b/src/service/feature/channel/api/channelAPI.ts index 1f66821..dea3dcb 100644 --- a/src/service/feature/channel/api/channelAPI.ts +++ b/src/service/feature/channel/api/channelAPI.ts @@ -1,89 +1,69 @@ import { createAxiosInstance } from '@service/feature/common/axios/axiosInstance'; -import { DMDetail, DMList } from '../types/channel'; +import { CreateChannelRequest, DMDetail, ChannelResponse } from '../types/channel'; +import { MoveChannelRequest } from '@service/feature/channel/types/category'; const axios = createAxiosInstance(); -export const getChannelList = async (teamId: string) => { - const res = await axios.get(`/teams/${teamId}`); +export const endpoints = { + teams: (teamId: string) => `/teams/${teamId}`, + categories: (teamId: string, categoryId: number) => `/teams/${teamId}/categories/${categoryId}/channels`, + channel: (channelId: number) => `/channels/${channelId}`, + dm: '/channels/me', +}; + +export const getChannelList = async (teamId: string): Promise => { + const res = await axios.get(endpoints.teams(teamId)); return res.data.data; }; -export const createChannel = async ({ - teamId, - categoryId, - name, - channelType, -}: { - teamId: string; - categoryId: number; - name: string; - channelType: 'TEXT' | 'VOICE'; -}) => { - const res = await axios.post( - `/teams/${teamId}/categories/${categoryId}/channels`, - { name, channelType }, - ); +export const createChannel = async ( + teamId: string, + categoryId: number, + request: CreateChannelRequest +): Promise => { + const res = await axios.post(endpoints.categories(teamId, categoryId), request); return res.data; }; -export const deleteChannel = async ({ - teamId, - categoryId, - channelId, -}: { - teamId: string; - categoryId: number; - channelId: number; -}) => { - const res = await axios.delete( - `/teams/${teamId}/categories/${categoryId}/channels/${channelId}`, - ); +export const deleteChannel = async ( + teamId: string, + categoryId: number, + channelId: number +): Promise => { + const res = await axios.delete(`${endpoints.categories(teamId, categoryId)}/${channelId}`); return res.data; }; export const moveChannel = async ( - teamId: string, - categoryId: number, - channelId: number, - body: { - destCategoryId: number; - prevChannelId: number; - nextChannelId: number; - }, -) => { - const res = await axios.patch( - `/teams/${teamId}/categories/${categoryId}/channels/${channelId}`, - body, - ); + teamId: string, + categoryId: number, + channelId: number, + body: MoveChannelRequest +): Promise => { + const res = await axios.patch(`${endpoints.categories(teamId, categoryId)}/${channelId}`, body); return res.data; }; -export const editChannel = async ({ - teamId, - categoryId, - channelId, -}: { - teamId: string; - categoryId: number; - channelId: number; -}) => { - const res = await axios.patch( - `/teams/${teamId}/categories/${categoryId}/channels/${channelId}`, - ); +export const editChannel = async ( + teamId: string, + categoryId: number, + channelId: number +): Promise => { + const res = await axios.patch(`${endpoints.categories(teamId, categoryId)}/${channelId}`); return res.data; }; export const getDMDetail = async (channelId: number): Promise => { - const res = await axios.get(`/channels/${channelId}`); + const res = await axios.get(endpoints.channel(channelId)); return res.data.data; }; export const getDMList = async (): Promise => { - const res = await axios.get(`/channels/me`); + const res = await axios.get(endpoints.dm); return res.data.data; }; export const createDM = async (memberIds: string[]): Promise => { - const res = await axios.post(`/channels/members`, { memberIds }); + const res = await axios.post('/channels/members', { memberIds }); return res.data.data; -}; +}; \ No newline at end of file diff --git a/src/service/feature/channel/hook/mutation/useCategoryMutation.ts b/src/service/feature/channel/hook/mutation/useCategoryMutation.ts new file mode 100644 index 0000000..512876d --- /dev/null +++ b/src/service/feature/channel/hook/mutation/useCategoryMutation.ts @@ -0,0 +1,15 @@ +import {useMutation, useQueryClient} from "@tanstack/react-query"; +import {toast} from "sonner"; +import {createCategory} from "@service/feature/channel/api/categorieAPI.ts"; + +export const useCreateCategoryMutation = (teamId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name }: { name: string }) => createCategory(teamId, name), + onSuccess: () => { + toast.success("카테고리 생성 완료!"); + queryClient.invalidateQueries({ queryKey: ["teamStructure", teamId] }); + }, + }); +}; \ No newline at end of file diff --git a/src/service/feature/channel/hook/mutation/useChannelMutation.ts b/src/service/feature/channel/hook/mutation/useChannelMutation.ts index 8038864..80b7730 100644 --- a/src/service/feature/channel/hook/mutation/useChannelMutation.ts +++ b/src/service/feature/channel/hook/mutation/useChannelMutation.ts @@ -1,54 +1,30 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { createChannel, deleteChannel, moveChannel } from '@service/feature/channel/api/channelAPI.ts'; +import {useApiMutation} from "@service/feature/common/hooks/useApiMutation.ts"; +import {CreateChannelRequest} from "@service/feature/channel/types/channel.ts"; +import {endpoints} from "@service/feature/channel/api/channelAPI.ts"; -export const useCreateChannelMutation = (serverId: string) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: createChannel, - onSuccess: () => { - toast.success('채널 생성 완료!'); - queryClient.invalidateQueries({ queryKey: ['serverChannels', serverId] }); - }, - }); +export const useCreateChannelMutation = (teamId: string, categoryId: number) => { + return useApiMutation<{ categoryId: number }, CreateChannelRequest, void>({ + urlBuilder: ({ categoryId }) => endpoints.categories(teamId, categoryId), + method: 'POST', + queryKeyToInvalidate: ['serverChannels', teamId], + }); }; -export const useDeleteChannelMutation = (serverId: string) => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deleteChannel, - onSuccess: () => { - toast.success('채널 삭제 완료!'); - queryClient.invalidateQueries({ queryKey: ['serverChannels', serverId] }); - }, - }); +export const useDeleteChannelMutation = (teamId: string) => { + return useApiMutation({ + urlBuilder: (params: { categoryId: number; channelId: number }) => + `${endpoints.categories(teamId, params.categoryId)}/${params.channelId}`, + method: 'DELETE', + queryKeyToInvalidate: ['serverChannels', teamId], + }); }; - export const useMoveChannelMutation = (teamId: string, categoryId: number) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ - channelId, - destCategoryId, - prevChannelId, - nextChannelId, - }: { - channelId: number; - destCategoryId: number; - prevChannelId: number; - nextChannelId: number; - }) => - moveChannel(teamId, categoryId, channelId, { - destCategoryId, - prevChannelId, - nextChannelId, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['teamStructure', teamId] }); - }, - }); -}; \ No newline at end of file + return useApiMutation({ + urlBuilder: (params: { channelId: number }) => + `${endpoints.categories(teamId, categoryId)}/${params.channelId}`, + method: 'PATCH', + queryKeyToInvalidate: ['teamStructure', teamId], + }); +}; diff --git a/src/service/feature/channel/schema/channelSchema.ts b/src/service/feature/channel/schema/channelSchema.ts new file mode 100644 index 0000000..8149768 --- /dev/null +++ b/src/service/feature/channel/schema/channelSchema.ts @@ -0,0 +1,21 @@ +import {z} from "zod"; + +const baseSchema = z.object({ + mode: z.enum(["channel", "category"]), +}); + +const channelSchema = baseSchema.extend({ + name: z.string().min(1, "채널 이름은 필수입니다"), + categoryId: z.string(), + channelType: z.enum(["TEXT", "VOICE"]), +}); + +const categorySchema = baseSchema.extend({ + categoryName: z.string().min(1, "카테고리 이름은 필수입니다"), +}); + +type ChannelFormValues = z.infer; +type CategoryFormValues = z.infer; + +export {baseSchema, channelSchema, categorySchema}; +export type { ChannelFormValues, CategoryFormValues }; diff --git a/src/service/feature/channel/types/category.ts b/src/service/feature/channel/types/category.ts new file mode 100644 index 0000000..3062112 --- /dev/null +++ b/src/service/feature/channel/types/category.ts @@ -0,0 +1,15 @@ +export enum ChannelType { + TEXT = 'TEXT', + VOICE = 'VOICE', +} + +export interface CreateCategoryResponse { + newCategoryId: string; + position: number; +} + +export interface MoveChannelRequest { + destCategoryId: number; + prevChannelId?: number; + nextChannelId?: number; +} \ No newline at end of file diff --git a/src/service/feature/channel/types/channel.ts b/src/service/feature/channel/types/channel.ts index ad8492e..7d900ff 100644 --- a/src/service/feature/channel/types/channel.ts +++ b/src/service/feature/channel/types/channel.ts @@ -1,12 +1,12 @@ -export type ChannelType = 'text' | 'voice' | 'event'; +import {ChannelType} from "@service/feature/channel/types/category.ts"; -export interface DMDetail { - channel: Channel; - channelMembers: ChannelMember[]; -} - -export interface DMList extends Channel { - channelMembers: ChannelMember[]; +export interface Channel { + id: number; + name: string; + position: number; + type: string; + accessType: string; + chatId: string; } export interface ChannelMember { @@ -18,45 +18,46 @@ export interface ChannelMember { createdAt: string; } -export interface Channel { +export interface DMDetail { + channel: Channel; + channelMembers: ChannelMember[]; +} + +export interface DMList extends Channel { + channelMembers: ChannelMember[]; +} + +export interface Category { id: number; name: string; position: number; - type: string; - accessType: string; - chatId: string; } export interface CategoryView { - category: { - id: number; - name: string; - position: number; - }; + category: Category; channels: Channel[]; - } -export interface ChannelResponse { - team: { - id: string; - name: string; - masterId: string; - iconUrl: string; - }; - categoriesView: CategoryView[]; - teamMembers: { - id: number; - role: 'OWNER' | 'MEMBER'; - memberInfo: ChannelMember; - }[]; +export interface TeamMember { + id: number; + role: 'OWNER' | 'MEMBER'; + memberInfo: ChannelMember; } -export interface ChannelMember { +export interface Team { id: string; - nickname: string; name: string; - avatarUrl: string; - state: 'ONLINE' | 'OFFLINE'; - createdAt: string; + masterId: string; + iconUrl: string; +} + +export interface CreateChannelRequest { + name: string; + channelType: ChannelType; +} + +export interface ChannelResponse { + team: Team; + categoriesView: CategoryView[]; + teamMembers: TeamMember[]; } \ No newline at end of file diff --git a/src/service/feature/chat/context/SocketContext.ts b/src/service/feature/chat/context/SocketContext.ts index 059ac5c..3085995 100644 --- a/src/service/feature/chat/context/SocketContext.ts +++ b/src/service/feature/chat/context/SocketContext.ts @@ -1,4 +1,4 @@ -import { createContext, useContext } from 'react'; +import { createContext } from 'react'; import type { CompatClient } from '@stomp/stompjs'; export type SocketContextType = { diff --git a/src/service/feature/chat/type/messages.ts b/src/service/feature/chat/type/messages.ts index 64309e9..d49339f 100644 --- a/src/service/feature/chat/type/messages.ts +++ b/src/service/feature/chat/type/messages.ts @@ -1,9 +1,14 @@ +import {ChatMessage} from "@service/feature/chat/schema/messageSchema.ts"; + export interface Chat { messageId: number; - sender: Sender; + sender: ChatMessage["sender"]; content: string; createdAt: string; isUpdated: boolean; isDeleted: boolean; attachments: any[]; + mentions: ChatMessage["mentions"]; + status: ChatMessage["status"]; + } diff --git a/src/service/feature/common/axios/axiosInstance.ts b/src/service/feature/common/axios/axiosInstance.ts index 793e542..582386f 100644 --- a/src/service/feature/common/axios/axiosInstance.ts +++ b/src/service/feature/common/axios/axiosInstance.ts @@ -2,6 +2,7 @@ import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios'; import { toast } from 'sonner'; import { ERROR_MESSAGES } from '../../../lib/const/toast/errorMessage'; import { getCookie } from '../../auth/lib/getCookie'; +import {useLogout} from "@service/feature/auth/hook/auth/useLogin.ts"; export type ServiceType = 'members' | 'teams' | 'dialog'; @@ -36,7 +37,7 @@ const handleAxiosError = (error: AxiosError) => { ); if (response.status === 401) { - // TODO: logout 처리 또는 /login 리디렉션 + useLogout() } return Promise.reject(error); diff --git a/src/service/feature/common/hooks/useApiMutation.ts b/src/service/feature/common/hooks/useApiMutation.ts new file mode 100644 index 0000000..a88a094 --- /dev/null +++ b/src/service/feature/common/hooks/useApiMutation.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +interface ApiMutationOptions { + urlBuilder: (params: TParams) => string; + method?: 'POST' | 'PATCH' | 'DELETE'; + queryKeyToInvalidate: string[]; +} + +export const useApiMutation = ({urlBuilder, method = 'POST', queryKeyToInvalidate,}: ApiMutationOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ params, body }) => { + const url = urlBuilder(params); + + if (method === 'POST') { + const res = await axios.post(url, body); // Body 포함 + return res.data; + } else if (method === 'PATCH') { + const res = await axios.patch(url, body); + return res.data; + } else if (method === 'DELETE') { + const res = await axios.delete(url); + return res.data; + } + + throw new Error(`Invalid method: ${method}`); + }, + onSuccess: () => { + queryClient.invalidateQueries(queryKeyToInvalidate); + }, + }); +}; \ No newline at end of file diff --git a/src/service/feature/member/types/memberAPI.ts b/src/service/feature/member/types/memberAPI.ts index 98fff08..f191d72 100644 --- a/src/service/feature/member/types/memberAPI.ts +++ b/src/service/feature/member/types/memberAPI.ts @@ -9,12 +9,12 @@ export type MemberState = | '오프라인'; export interface MemberInfo { - userId: string; - email: string; - nickname: string; - avatarUrl?: string; - birth?: string; - state?: MemberState; + id: string; + nickname: string; + name: string; + avatarUrl: string; + state: MemberState; + createdAt: string; } export interface UpdateMemberStatusRequest { diff --git a/src/service/feature/team/store/teamSlice.ts b/src/service/feature/team/store/teamSlice.ts new file mode 100644 index 0000000..07519c2 --- /dev/null +++ b/src/service/feature/team/store/teamSlice.ts @@ -0,0 +1,36 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { getTeamById } from '@service/feature/team/api/teamsServiceAPI'; + +export const fetchTeamDetails = createAsyncThunk('teams/fetchTeamDetails', async (teamId: string) => { + const data = await getTeamById(teamId); + return data; +}); + +const initialState = { + teamDetails: null, + loading: false, + error: null, +}; + +const teamSlice = createSlice({ + name: 'teams', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchTeamDetails.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchTeamDetails.fulfilled, (state, action) => { + state.loading = false; + state.teamDetails = action.payload; + }) + .addCase(fetchTeamDetails.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message; + }); + }, +}); + +export default teamSlice.reducer; \ No newline at end of file diff --git a/src/view/layout/profile/UserProfileBar.tsx b/src/view/layout/profile/UserProfileBar.tsx index eac38ea..3d16003 100644 --- a/src/view/layout/profile/UserProfileBar.tsx +++ b/src/view/layout/profile/UserProfileBar.tsx @@ -1,15 +1,25 @@ -import { Settings } from 'lucide-react'; import Avatar from '@components/common/user/Avatar.tsx'; import UserStatus from '@components/common/user/UserStatus.tsx'; -import { useSelector } from 'react-redux'; +import {useDispatch, useSelector} from 'react-redux'; import { RootState } from '../../../app/store.ts'; +import UserProfileContextMenu from "./component/ProfileContextMenu.tsx"; +import {MemberState} from "@service/feature/auth/types/profile.ts"; +import {updateStatus} from "@service/feature/auth/api/profileApi.ts"; const UserProfileBar = () => { - const profile = useSelector( - (state: RootState) => (state.auth as any).profile, - ); + const profile = useSelector((state: RootState) => (state.auth as any).profile); + + const dispatch = useDispatch(); + + const handleEditProfile = () => { + console.log('내 정보 수정 클릭'); + }; - return ( + const handleChangeStatus = (status: MemberState) => { + dispatch(updateStatus(status)); + }; + + return (
{
- + + ); }; diff --git a/src/view/layout/profile/component/EditProfileModal.tsx b/src/view/layout/profile/component/EditProfileModal.tsx new file mode 100644 index 0000000..77b167b --- /dev/null +++ b/src/view/layout/profile/component/EditProfileModal.tsx @@ -0,0 +1,84 @@ +import React, {useState} from 'react'; +import {updateProfile} from '@service/feature/auth/api/profileApi'; + +interface EditProfileModalProps { + onClose: () => void; +} + +const EditProfileModal: React.FC = ({onClose}) => { + const [name, setName] = useState(''); + const [birth, setBirth] = useState(''); + const [password, setPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [avatarUrl, setAvatarUrl] = useState(''); + + const handleSubmit = async () => { + try { + await updateProfile({ + birth, + name, + password, + newPassword, + avatarUrl + }); + alert('정보가 수정되었습니다.'); + onClose(); + } catch (error) { + console.error(error); + alert('수정 중 오류가 발생했습니다.'); + } + }; + + return ( +
+
+

내 정보 수정

+ setName(e.target.value)} + className="w-full px-3 py-2 border rounded mb-2" + /> + setBirth(e.target.value)} + className="w-full px-3 py-2 border rounded mb-2" + /> + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded mb-2" + /> + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border rounded mb-2" + /> + setAvatarUrl(e.target.value)} + className="w-full px-3 py-2 border rounded mb-2" + /> +
+ + +
+
+
+ ); +}; + +export default EditProfileModal; \ No newline at end of file diff --git a/src/view/layout/profile/component/ProfileContextMenu.tsx b/src/view/layout/profile/component/ProfileContextMenu.tsx new file mode 100644 index 0000000..12ad237 --- /dev/null +++ b/src/view/layout/profile/component/ProfileContextMenu.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import {Settings} from "lucide-react"; +import {MemberState} from "@service/feature/auth/types/profile.ts"; +import {useLogout} from "@service/feature/auth/hook/auth/useLogin.ts"; +import EditProfileModal from './EditProfileModal'; +import UpdateStatusModal from "./UpdateStatusModal.tsx"; + +interface UserProfileContextMenuProps { + onEditProfile: () => void; + onChangeStatus: (state: MemberState) => void; +} + +const UserProfileContextMenu: React.FC = () => { + const logout = useLogout(); + const [isProfileModalOpen, setProfileModalOpen] = useState(false); + const [isStatusModalOpen, setStatusModalOpen] = useState(false); + + const handleProfileEdit = () => { + setProfileModalOpen(true); + }; + + const handleStatusChange = () => { + setStatusModalOpen(true); + }; + + const handleLogout = () => { + logout(); + }; + + + return ( +
+ +
+
+ 내 정보 수정 +
+
+ 내 상태 변경 +
+
+ 로그아웃 +
+
+ {isProfileModalOpen && ( + setProfileModalOpen(false)} /> + )} + {isStatusModalOpen && ( + setStatusModalOpen(false)} /> + )} + +
+ ); +}; + +export default UserProfileContextMenu; \ No newline at end of file diff --git a/src/view/layout/profile/component/UpdateStatusModal.tsx b/src/view/layout/profile/component/UpdateStatusModal.tsx new file mode 100644 index 0000000..5185743 --- /dev/null +++ b/src/view/layout/profile/component/UpdateStatusModal.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { updateStatus } from '@service/feature/auth/api/profileApi'; +import {MemberState} from "@service/feature/auth/types/profile.ts"; + +interface UpdateStatusModalProps { + onClose: () => void; +} + +const UpdateStatusModal: React.FC = ({ onClose }) => { + const [selectedStatus, setSelectedStatus] = useState('ONLINE'); + + const handleStatusChange = async () => { + try { + await updateStatus(selectedStatus); + alert(`상태가 ${selectedStatus}로 변경되었습니다.`); + onClose(); + } catch (error) { + console.error(error); + alert('상태 변경 중 오류가 발생했습니다.'); + } + }; + + return ( +
+
+

내 상태 변경

+ +
+ + +
+
+
+ ); +}; + +export default UpdateStatusModal; \ No newline at end of file diff --git a/src/view/layout/sidebar/components/channel/ChannelCategory.tsx b/src/view/layout/sidebar/components/channel/ChannelCategory.tsx index 2f96c2d..00ec3ab 100644 --- a/src/view/layout/sidebar/components/channel/ChannelCategory.tsx +++ b/src/view/layout/sidebar/components/channel/ChannelCategory.tsx @@ -1,21 +1,20 @@ import { useState } from 'react'; -import {DndContext, DragEndEvent} from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, - arrayMove, -} from '@dnd-kit/sortable'; +import { DndContext, DragEndEvent } from '@dnd-kit/core'; +import {SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; import { ChevronDown, ChevronRight, Plus } from 'lucide-react'; import ChannelItem from './ChannelItem.tsx'; import { Channel } from '@service/feature/channel/types/channel.ts'; +import ChannelAddDialog from "./ChannelDialog.tsx"; -const ChannelCategory = ({title, type, defaultItems,}: { +const ChannelCategory = ({ title, type, defaultItems, serverId }: { title: string; type: 'text' | 'voice' | 'event'; defaultItems: Channel[]; + serverId: string; }) => { const [isOpen, setIsOpen] = useState(true); const [items, setItems] = useState(defaultItems); + const [isDialogOpen, setIsDialogOpen] = useState(false); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; @@ -36,7 +35,11 @@ const ChannelCategory = ({title, type, defaultItems,}: { {isOpen ? : } {title} - + setIsDialogOpen(true)} + /> {isOpen && ( @@ -56,6 +59,10 @@ const ChannelCategory = ({title, type, defaultItems,}: { )} + + {isDialogOpen && ( + setIsDialogOpen(false)} /> + )} ); }; diff --git a/src/view/layout/sidebar/components/channel/ChannelDialog.tsx b/src/view/layout/sidebar/components/channel/ChannelDialog.tsx new file mode 100644 index 0000000..17368dd --- /dev/null +++ b/src/view/layout/sidebar/components/channel/ChannelDialog.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { useCreateChannelMutation } from "@service/feature/channel/hook/mutation/useChannelMutation.ts"; +import { useCreateCategoryMutation } from "@service/feature/channel/hook/mutation/useCategoryMutation.ts"; +import { + CategoryFormValues, + ChannelFormValues, + channelSchema, +} from "@service/feature/channel/schema/channelSchema.ts"; + +const ChannelAddDialog = ({ + teamId, + onClose, +}: { + teamId: string; + onClose: () => void; +}) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [mode, setMode] = useState<"channel" | "category">("channel"); + + const createChannelMutation = useCreateChannelMutation(teamId); + const createCategoryMutation = useCreateCategoryMutation(teamId); + + const { register, handleSubmit, formState, reset } = useForm({ + resolver: zodResolver(channelSchema), + defaultValues: { mode: "channel" }, + }); + + const { errors } = formState; + + const handleModeChange = (newMode: "channel" | "category") => { + setMode(newMode); + reset(); + }; + + const onSubmit = async (data: ChannelFormValues | CategoryFormValues) => { + setIsSubmitting(true); + + try { + if (mode === "channel") { + const channelData = data as ChannelFormValues; + + await createChannelMutation.mutateAsync({ + name: channelData.name, + categoryId: Number(channelData.categoryId), + channelType: channelData.channelType || "TEXT", + }); + + toast.success("채널이 성공적으로 추가되었습니다!"); + } else if (mode === "category") { + const categoryData = data as CategoryFormValues; + + await createCategoryMutation.mutateAsync({ + name: categoryData.categoryName, + }); + + toast.success("카테고리가 성공적으로 추가되었습니다!"); + } + + onClose(); + } catch (error) { + toast.error("추가 중 오류가 발생했습니다."); + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

+ {mode === "channel" ? "채널 추가" : "카테고리 추가"} +

+
+ +
+ + +
+
+ +
+ {mode === "channel" && ( + <> +
+ + + {errors.mode?.message && ( + + {errors.mode.message} + + )} +
+ +
+ + +
+ +
+ + + {errors.mode?.message && ( + + {errors.mode.message} + + )} +
+ + )} + + {mode === "category" && ( +
+ + + {errors.mode?.message && ( + + {errors.mode.message} + + )} +
+ )} + +
+ + +
+
+
+
+ ); +}; + +export default ChannelAddDialog; \ No newline at end of file diff --git a/src/view/pages/auth/login/components/LoginForm.tsx b/src/view/pages/auth/login/components/LoginForm.tsx index 6c159a0..5dec830 100644 --- a/src/view/pages/auth/login/components/LoginForm.tsx +++ b/src/view/pages/auth/login/components/LoginForm.tsx @@ -1,7 +1,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { loginSchema, LoginFormData } from '../../../../../service/feature/auth/schema/authSchema'; -import { useLogin } from '../../../../../service/feature/auth'; +import { loginSchema, LoginFormData } from '@service/feature/auth/schema/authSchema.ts'; +import { useLogin } from '@service/feature/auth'; import { toast } from 'sonner'; import LoginTextInput from '@pages/auth/components/LoginTextInput.tsx'; diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index f920950..35a7604 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -3,7 +3,7 @@ import { useChat } from "@service/feature/chat/hook/useChat.ts"; import { ChatMessage } from "@service/feature/chat/schema/messageSchema.ts"; import { useState, useCallback, useEffect, useMemo } from "react"; import { ChannelHeader } from "./components/layout/ChannelHeader"; -import { ChatInput } from "@pages/chat/components/layout/ChatInput.tsx"; +import { ChatInput } from "@pages/chat/components/input/ChatInput.tsx"; import { ChatView } from "@pages/chat/components/layout/ChatView.tsx"; import { postImage } from "@service/feature/image/imageApi.ts"; import { useParams } from "react-router-dom"; @@ -38,9 +38,6 @@ export function ChatPage() { } }, [messagesData]); - console.log(useMessageHistory(channelId)); - console.log(messagesData); - const handleNewMessage = useCallback((msg: ChatMessage) => { setLocalMessages((prev) => { if (msg.tempId) { diff --git a/src/view/pages/chat/components/input/ChatInput.tsx b/src/view/pages/chat/components/input/ChatInput.tsx new file mode 100644 index 0000000..3eab7e0 --- /dev/null +++ b/src/view/pages/chat/components/input/ChatInput.tsx @@ -0,0 +1,137 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChannelMember } from '@service/feature/channel/types/channel.ts'; + +interface ChatInputProps { + onSend: (text: string, memberIds: string[], fileList?: File[]) => void; + users: ChannelMember[]; +} + +export const ChatInput = ({ onSend, users }: ChatInputProps) => { + const [text, setText] = useState(''); + const [mentionList, setMentionList] = useState([]); + const [showMentionList, setShowMentionList] = useState(false); + const [mentionMap, setMentionMap] = useState>(new Map()); + const dropdownRef = useRef(null); + const fileInputRef = useRef(null); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setText(value); + + const mentionTriggerIndex = value.lastIndexOf('@'); + if (mentionTriggerIndex !== -1) { + const query = value.slice(mentionTriggerIndex + 1).toLowerCase(); + if (query.trim()) { + const filteredUsers = users.filter(user => + user.name.toLowerCase().includes(query) || user.nickname.toLowerCase().includes(query) + ); + setMentionList(filteredUsers); + setShowMentionList(true); + } else { + setMentionList([]); + setShowMentionList(true); + } + } else { + setShowMentionList(false); + } + }; + + const addMention = (user: ChannelMember) => { + const mentionTriggerIndex = text.lastIndexOf('@'); + const prefix = text.slice(0, mentionTriggerIndex); + const withMention = `${prefix}@${user.nickname} `; + setText(withMention); + setMentionMap(prev => new Map(prev).set(user.nickname, user.id)); + setShowMentionList(false); + }; + + const handleOutsideClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowMentionList(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, []); + + const extractMentions = (): string[] => { + const mentionRegex = /@([^\s]+)/g; + const matches = [...text.matchAll(mentionRegex)]; + return matches + .map(match => match[1]) + .filter(nickname => mentionMap.has(nickname)) + .map(nickname => mentionMap.get(nickname)!); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim()) return; + + const memberIds = extractMentions(); + onSend(text, memberIds); + setText(''); + setMentionMap(new Map()); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + if (!e.target.files) return; + + const files = Array.from(e.target.files); + const memberIds = extractMentions(); + onSend(text.trim(), memberIds, files); + setText(''); + setMentionMap(new Map()); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + return ( +
+
+ + + +
+ + {showMentionList && ( +
+ {mentionList.length > 0 ? ( + mentionList.map(user => ( +
addMention(user)} + > + {user.name} + {user.name} (@{user.nickname}) +
+ )) + ) : ( +
멘션 가능한 멤버가 없습니다.
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/view/pages/chat/components/input/MemberDropdown.tsx b/src/view/pages/chat/components/input/MemberDropdown.tsx new file mode 100644 index 0000000..1093745 --- /dev/null +++ b/src/view/pages/chat/components/input/MemberDropdown.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { fetchTeamDetails } from '@service/feature/team/store/teamSlice'; +import { TeamMembers } from '@service/feature/team/types/team'; +import { RootState } from "../../../../../app/store.ts"; + +interface DropdownProps { + teamId: string; + onSelect: (member: string) => void; +} + +export const TeamMemberDropdown: React.FC = ({ teamId, onSelect }) => { + const dispatch = useDispatch(); + const [searchTerm, setSearchTerm] = useState(''); + + const { teamDetails, loading, error } = useSelector( + (state: RootState) => ({ + teamDetails: state.teams.teamDetails, + loading: state.teams.loading, + error: state.teams.error, + }) + ); + + useEffect(() => { + if (!teamDetails || teamDetails.id !== teamId) { + dispatch(fetchTeamDetails(teamId) as any); + } + }, [dispatch, teamId, teamDetails]); + + if (loading) return
로딩 중...
; + if (error) return
멤버를 가져오는 중 오류가 발생했습니다.
; + + const teamMembers = teamDetails?.teamMembers || []; + const filteredMembers = teamMembers.filter((member: TeamMembers) => + member.memberInfo.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+ setSearchTerm(e.target.value)} + /> + {filteredMembers.length > 0 && ( +
    + {filteredMembers.map((member: TeamMembers) => ( +
  • onSelect(member.memberInfo.nickname)} + > + {member.memberInfo.nickname} +
  • + ))} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChatInput.tsx b/src/view/pages/chat/components/layout/ChatInput.tsx deleted file mode 100644 index e13a9c4..0000000 --- a/src/view/pages/chat/components/layout/ChatInput.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { ChannelMember } from '@service/feature/channel/types/channel.ts'; - -interface ChatInputProps { - onSend: (text: string, mentions: string[]) => void; - users: ChannelMember[]; -} - -export const ChatInput = ({ onSend, users }: ChatInputProps) => { - const [text, setText] = useState(''); - const [mentionList, setMentionList] = useState([]); - const [showMentionList, setShowMentionList] = useState(false); - const [mentionMap, setMentionMap] = useState>(new Map()); - const dropdownRef = useRef(null); - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setText(value); - - const mentionTriggerIndex = value.lastIndexOf('@'); - if (mentionTriggerIndex !== -1) { - const query = value.slice(mentionTriggerIndex + 1).toLowerCase(); - if (query.trim()) { - const filteredUsers = users.filter(user => - user.name.toLowerCase().includes(query) || user.nickname.toLowerCase().includes(query) - ); - setMentionList(filteredUsers); - setShowMentionList(true); - } else { - setMentionList([]); - setShowMentionList(true); - } - } else { - setShowMentionList(false); - } - }; - - const addMention = (user: ChannelMember) => { - const mentionTriggerIndex = text.lastIndexOf('@'); - const prefix = text.slice(0, mentionTriggerIndex); - const withMention = `${prefix}@${user.name} `; - setText(withMention); - setMentionMap(prev => new Map(prev).set(user.name, user.id)); - setShowMentionList(false); - }; - - const handleOutsideClick = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setShowMentionList(false); - } - }; - - useEffect(() => { - document.addEventListener('mousedown', handleOutsideClick); - return () => document.removeEventListener('mousedown', handleOutsideClick); - }, []); - - const extractMentions = (text: string): string[] => { - const mentionRegex = /@([^\s]+)/g; - const matches = [...text.matchAll(mentionRegex)]; - return matches - .map(match => match[1]) - .filter(mention => mentionMap.has(mention)) - .map(mention => mentionMap.get(mention)!); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!text.trim()) return; - - const mentions = extractMentions(text); - onSend(text, mentions); - setText(''); - setMentionMap(new Map()); - }; - - return ( -
-
- - -
- - {showMentionList && ( -
- {mentionList.length > 0 ? ( - mentionList.map(user => ( -
addMention(user)} - > - {user.nickname} - {user.name} ({user.nickname}) -
- )) - ) : ( -
- 멘션 가능한 멤버가 없습니다. -
- )} -
- )} -
- ); -}; \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChatMemberDialog.tsx b/src/view/pages/chat/components/layout/ChatMemberDialog.tsx index 80f717a..90494d7 100644 --- a/src/view/pages/chat/components/layout/ChatMemberDialog.tsx +++ b/src/view/pages/chat/components/layout/ChatMemberDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { ChannelMember } from '@service/feature/channel/types/channel.ts'; import { X } from 'lucide-react'; import fallbackImg from '@assets/img/chatflow.png' diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index ee295a3..9f4c8be 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -1,11 +1,11 @@ import { useEffect, useRef } from 'react'; -import { Chat } from '@service/feature/chat/type/messages.ts' import { DateDivider } from '@pages/chat/components/message/DateDivider.tsx'; import { ChatMessageItem } from '@pages/chat/components/message/ChatMessageItem.tsx'; import {CategoryView} from "@service/feature/channel/types/channel.ts"; +import {ChatMessage} from "@service/feature/chat/schema/messageSchema.ts"; export const ChatView = ({messages = [], myId }: { - messages: Chat[]; + messages: ChatMessage[]; myId: string; categories: CategoryView }) => { @@ -31,6 +31,7 @@ export const ChatView = ({messages = [], myId }: { const prev = messageList[index - 1]; const isSameSender = prev?.sender?.memberId === msg.sender?.memberId; const showMeta = !isSameSender || shouldShowDateDivider(msg, prev); + console.log(msg) return (
diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index b35aaa9..90b5ffe 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -103,7 +103,7 @@ export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => {
{msg.content && ( -

{parseMentions(msg.content, mentions)}

+

{parseMentions(msg.content, mentions)}

)} {msg.attachments && (
From afdc46fe5a2f634cfc80b8888a08ff41714a18e5 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Sun, 27 Jul 2025 22:18:43 +0900 Subject: [PATCH 02/12] =?UTF-8?q?FLOW-35:=20=EB=82=B4=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/feature/common/axios/axiosInstance.ts | 2 +- .../layout/profile/component/EditProfileModal.tsx | 12 ++++++------ .../layout/profile/component/UpdateStatusModal.tsx | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/service/feature/common/axios/axiosInstance.ts b/src/service/feature/common/axios/axiosInstance.ts index 582386f..f6c4698 100644 --- a/src/service/feature/common/axios/axiosInstance.ts +++ b/src/service/feature/common/axios/axiosInstance.ts @@ -37,7 +37,7 @@ const handleAxiosError = (error: AxiosError) => { ); if (response.status === 401) { - useLogout() + // useLogout() } return Promise.reject(error); diff --git a/src/view/layout/profile/component/EditProfileModal.tsx b/src/view/layout/profile/component/EditProfileModal.tsx index 77b167b..27e4cfe 100644 --- a/src/view/layout/profile/component/EditProfileModal.tsx +++ b/src/view/layout/profile/component/EditProfileModal.tsx @@ -31,42 +31,42 @@ const EditProfileModal: React.FC = ({onClose}) => { return (
-
+

내 정보 수정

setName(e.target.value)} - className="w-full px-3 py-2 border rounded mb-2" + className="w-full px-3 py-2 border rounded bg-chat mb-2" /> setBirth(e.target.value)} - className="w-full px-3 py-2 border rounded mb-2" + className="w-full px-3 py-2 border rounded bg-chat mb-2" /> setPassword(e.target.value)} - className="w-full px-3 py-2 border rounded mb-2" + className="w-full px-3 py-2 border rounded bg-chat mb-2" /> setNewPassword(e.target.value)} - className="w-full px-3 py-2 border rounded mb-2" + className="w-full px-3 py-2 border rounded bg-chat mb-2" /> setAvatarUrl(e.target.value)} - className="w-full px-3 py-2 border rounded mb-2" + className="w-full px-3 py-2 border rounded bg-chat mb-2" />