diff --git a/package.json b/package.json index ce47fa4..c90fa34 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@tanstack/react-query": "^5.69.0", "axios": "^1.7.9", "clsx": "^2.1.1", - "event-source-polyfill": "^1.0.31", "firebase": "^11.5.0", "framer-motion": "^12.6.2", "idb": "^8.0.2", 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..860700a 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 = () => { + return useApiMutation<{ teamId: string; categoryId: number }, CreateChannelRequest, void>({ + urlBuilder: ({ teamId, categoryId }) => endpoints.categories(teamId, categoryId), + method: 'POST', + queryKeyToInvalidate: (params) => ['serverChannels', params.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/hook/useChat.ts b/src/service/feature/chat/hook/useChat.ts index c186fe9..6d49e82 100644 --- a/src/service/feature/chat/hook/useChat.ts +++ b/src/service/feature/chat/hook/useChat.ts @@ -61,6 +61,8 @@ export const useChat = (chatId: string | undefined, onMessage: (msg: ChatMessage attachments, }; + // 메시지 전송 로깅은 필요 시 적절한 로깅 메커니즘으로 대체해야 합니다. + return new Promise((resolve, reject) => { try { client.publish({ diff --git a/src/service/feature/chat/schema/messageSchema.ts b/src/service/feature/chat/schema/messageSchema.ts index c78a778..23264e3 100644 --- a/src/service/feature/chat/schema/messageSchema.ts +++ b/src/service/feature/chat/schema/messageSchema.ts @@ -7,7 +7,7 @@ const senderSchema = z.object({ }); const attachmentSchema = z.object({ - type: z.enum(['image', 'file']), + type: z.string(), url: z.string(), }); 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..09f2de7 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 e6694ef..859bef8 100644 --- a/src/service/feature/member/types/memberAPI.ts +++ b/src/service/feature/member/types/memberAPI.ts @@ -9,13 +9,14 @@ export type MemberState = | '오프라인'; export interface MemberInfo { - userId: string; - email: string; - name: string; - nickname: string; - avatarUrl?: string; - birth?: string; - state?: MemberState; + id: string; + nickname: string; + name: string; + avatarUrl: string; + state: MemberState; + createdAt: string; + email?: string; + birth?: string; } export interface UpdateMemberStatusRequest { diff --git a/src/service/feature/team/api/teamsServiceAPI.ts b/src/service/feature/team/api/teamsServiceAPI.ts index 76a0eba..77c6f51 100644 --- a/src/service/feature/team/api/teamsServiceAPI.ts +++ b/src/service/feature/team/api/teamsServiceAPI.ts @@ -23,7 +23,11 @@ export const getTeamList = async () => { export const getTeamById = async (teamId: string | undefined) => { const response = await axios.get(`/teams/${teamId}`); - return response.data.data; + return { + ...response.data.data.team, + categoriesView: response.data.data.categoriesView, + teamMembers: response.data.data.teamMembers + }; }; export const deleteTeam = async (teamId: string) => { diff --git a/src/service/feature/team/store/teamSlice.ts b/src/service/feature/team/store/teamSlice.ts new file mode 100644 index 0000000..ce17789 --- /dev/null +++ b/src/service/feature/team/store/teamSlice.ts @@ -0,0 +1,35 @@ +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) => { + state.loading = false; + }); + }, +}); + +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..4f983fe 100644 --- a/src/view/layout/profile/UserProfileBar.tsx +++ b/src/view/layout/profile/UserProfileBar.tsx @@ -1,13 +1,28 @@ -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 { RootState } from '../../../app/store.ts'; +import { updateStatus } from "@service/feature/auth/api/profileApi.ts"; +import { RootState, } from "../../../app/store.ts"; +import { MemberState } from "@service/feature/auth/types/profile.ts"; +import UserProfileContextMenu from "./component/ProfileContextMenu.tsx"; +import {useDispatch, useSelector} from "react-redux"; 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('내 정보 수정 클릭'); + }; + + const handleChangeStatus = async (status: MemberState) => { + try { + await updateStatus(status); + console.log('Status updated successfully'); + } catch (error) { + console.error('Failed to update status:', error); + } + }; return (
@@ -25,9 +40,12 @@ const UserProfileBar = () => {
- + ); }; -export default UserProfileBar; +export default UserProfileBar; \ No newline at end of file diff --git a/src/view/layout/profile/component/EditProfileModal.tsx b/src/view/layout/profile/component/EditProfileModal.tsx new file mode 100644 index 0000000..27e4cfe --- /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 bg-chat mb-2" + /> + setBirth(e.target.value)} + 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 bg-chat mb-2" + /> + setNewPassword(e.target.value)} + 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 bg-chat 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..6e73347 --- /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..83e22aa 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -3,12 +3,10 @@ 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"; import { v4 as uuidv4 } from "uuid"; -import { toast } from "sonner"; import { useTeamDetailQuery } from "@service/feature/team/hook/query/useTeamServiceQuery.ts"; import { useSelector } from "react-redux"; import { ChannelMember } from "@service/feature/channel/types/channel.ts"; @@ -38,9 +36,6 @@ export function ChatPage() { } }, [messagesData]); - console.log(useMessageHistory(channelId)); - console.log(messagesData); - const handleNewMessage = useCallback((msg: ChatMessage) => { setLocalMessages((prev) => { if (msg.tempId) { @@ -54,52 +49,31 @@ export function ChatPage() { const { sendMessage } = useChat(channelId, handleNewMessage); - const uploadImage = async (file: File): Promise => { - const formData = new FormData(); - formData.append("file", file); - - try { - return await postImage(formData); - } catch (error) { - toast.error("이미지 업로드 실패:"); - throw error; - } - }; - - const handleSend = async (text: string, mentionsOrFiles?: string[] | File[]) => { + const handleSend = async (text: string, mentionsOrAttachments: string[] | { type: string; url: string }[] = []) => { let mentionList: string[] = []; - let fileList: File[] = []; + let attachments: { type: string; url: string }[] = []; - if (Array.isArray(mentionsOrFiles)) { - if (typeof mentionsOrFiles[0] === "string") { - mentionList = mentionsOrFiles as string[]; + if (Array.isArray(mentionsOrAttachments)) { + if (typeof mentionsOrAttachments[0] === 'string') { + mentionList = mentionsOrAttachments as string[]; } else { - fileList = mentionsOrFiles as File[]; + attachments = mentionsOrAttachments as { type: string; url: string }[]; } } - let imageUrls: string[] = []; - if (fileList.length > 0) { - const uploadPromises = fileList.map((file) => uploadImage(file)); - imageUrls = await Promise.all(uploadPromises); - } - const tempMessage: ChatMessage = { tempId: uuidv4(), sender: { - memberId: currentUser?.id || "", - name: currentUser?.name || "알 수 없음", - avatarUrl: currentUser?.avatarUrl || "", + memberId: currentUser?.id || '', + name: currentUser?.name || '알 수 없음', + avatarUrl: currentUser?.avatarUrl || '', }, content: text, createdAt: new Date().toISOString(), isUpdated: false, isDeleted: false, - status: "pending", - attachments: - imageUrls.length > 0 - ? imageUrls.map((url) => ({ type: "image" as const, url })) - : [], + status: 'pending', + attachments, mentions: mentionList, messageId: 0, }; @@ -107,16 +81,17 @@ export function ChatPage() { setLocalMessages((prev) => [...prev, tempMessage]); try { - await sendMessage(text, tempMessage.attachments); + await sendMessage(text, attachments); } catch (error) { + console.error('메시지 전송 오류:', error); + setLocalMessages((prev) => - prev.map((msg) => - msg.tempId === tempMessage.tempId - ? { ...msg, status: "error" as const } - : msg - ) + prev.map((msg) => + msg.tempId === tempMessage.tempId + ? { ...msg, status: 'error' } + : msg + ) ); - console.error("메시지 전송 실패:", error); } }; 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..17242ba --- /dev/null +++ b/src/view/pages/chat/components/input/ChatInput.tsx @@ -0,0 +1,160 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChannelMember } from '@service/feature/channel/types/channel.ts'; +import {postImage} from "@service/feature/image/imageApi.ts"; + +interface ChatInputProps { + onSend: (text: string, memberIds: string[], fileList?: { type: string; url: 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 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 = async (e: React.ChangeEvent) => { + if (!e.target.files) return; + + const files = Array.from(e.target.files); + + const tempAttachments = files.map((file) => ({ + type: 'image', + url: URL.createObjectURL(file), + })); + + // Display local previews of the files while the upload is in progress + setLocalPreviews(tempAttachments); + + try { + const uploadedUrls = await Promise.all( + files.map(async (file) => { + const formData = new FormData(); + formData.append('file', file); + const url = await postImage(formData); + return { type: 'image', url }; + }) + ); + onSend(text.trim() || 'Uploaded files', memberIds, uploadedUrls); + } catch (error) { + console.error('이미지 업로드 실패:', error); + alert('이미지 업로드 중 문제가 발생했습니다.'); + } + + 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/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 73e521a..e835b2a 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[] }) => { @@ -34,7 +34,7 @@ export const ChatView = ({messages = [], myId }: { return (
{shouldShowDateDivider(msg, prev) && ()} - +
); })} diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index b35aaa9..0ad5a86 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -10,7 +10,7 @@ interface Props { msg: ChatMessage; isMine: boolean; showMeta: boolean; - mentions: string[]; + memberIds: string[]; } const MessageStatus = ({ status }: { status?: string }) => { @@ -32,9 +32,9 @@ const MessageStatus = ({ status }: { status?: string }) => { ); }; -const parseMentions = (text: string, mentions: string[]) => { +const parseMentions = (text: string, memberIds: string[] = []) => { return text.split(/(\@[^\s]+)/g).map((part, index) => { - if (part.startsWith('@') && mentions.includes(part.slice(1))) { + if (part.startsWith('@') && memberIds.includes(part.slice(1))) { return ( {part} @@ -52,29 +52,29 @@ const parseMentions = (text: string, mentions: string[]) => { }); }; -export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => { +export const ChatMessageItem = ({ msg, isMine, showMeta, memberIds }: Props) => { const renderAttachment = (attachment: { type: string; url: string }) => { if (attachment.type === 'image') { return ( - 첨부 이미지 + 첨부 이미지 ); } return ( - - - 첨부 파일 다운로드 - + + + 첨부 파일 다운로드 + ); }; @@ -103,7 +103,7 @@ export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => {
{msg.content && ( -

{parseMentions(msg.content, mentions)}

+

{parseMentions(msg.content, memberIds)}

)} {msg.attachments && (