From 2e1a97eab9de6f9d18d082d08b89a926b6efed8a Mon Sep 17 00:00:00 2001 From: hye-inA Date: Sun, 25 Jan 2026 03:04:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor=20:=20=ED=94=84=EB=A6=AC=ED=86=A0?= =?UTF-8?q?=ED=82=B9=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../freetalk/hooks/useChatWebSocket.js | 11 +- src/domains/freetalk/pages/ChatRoomPage.jsx | 101 +++++++++--------- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index fd6eaaa..633d755 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -1,6 +1,6 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {chatWebSocketService} from '../services/chatWebSocketService' -import {chatRoomService} from '../../chat/services/chatService' +import { useCallback, useEffect, useRef, useState } from 'react' +import { chatWebSocketService } from '../services/chatWebSocketService' +import { chatRoomService } from '../../chat/services/chatService' /** * Chat WebSocket 훅 @@ -26,10 +26,10 @@ export function useChatWebSocket(roomId, userId) { * WebSocket 연결 */ const connect = useCallback(async (forceNewToken = false) => { - console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId, forceNewToken}) + console.log('[useChatWebSocket] Attempting to connect...', { roomId, userId, forceNewToken }) if (!roomId || !userId) { - console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId}) + console.error('[useChatWebSocket] roomId and userId are required', { roomId, userId }) return } @@ -74,6 +74,7 @@ export function useChatWebSocket(roomId, userId) { id: messageId, content: data.content, userId: data.userId, + nickname: data.nickname, messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 2a220d6..781a891 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -1,5 +1,5 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {useNavigate, useParams} from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Alert, AppBar, @@ -21,31 +21,31 @@ import { Send as SendIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' -import {chatRoomService, messageService, voiceService} from '../../chat/services/chatService' -import {useAuth} from '../../../contexts/AuthContext' -import {useChatWebSocket} from '../hooks/useChatWebSocket' -import {useThemeMode} from '../../../contexts/ThemeContext' +import { chatRoomService, messageService, voiceService } from '../../chat/services/chatService' +import { useAuth } from '../../../contexts/AuthContext' +import { useChatWebSocket } from '../hooks/useChatWebSocket' +import { useThemeMode } from '../../../contexts/ThemeContext' import CommandAutocomplete from '../components/CommandAutocomplete' import PollCard from '../components/PollCard' import SystemCommandMessage from '../components/SystemCommandMessage' -import {parseCommand, MessageType} from '../types/chatCommandTypes' +import { parseCommand, MessageType } from '../types/chatCommandTypes' const levelColors = { - beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, - intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, - advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, } const ChatRoomPage = () => { - const {roomId} = useParams() + const { roomId } = useParams() const navigate = useNavigate() - const {user} = useAuth() - const {mode} = useThemeMode() + const { user } = useAuth() + const { mode } = useThemeMode() const isDark = mode === 'dark' const currentUserId = user?.userId || user?.username || user?.sub // 디버깅: 사용자 정보 확인 - console.log('[ChatRoomPage] User info:', {user, currentUserId, roomId}) + console.log('[ChatRoomPage] User info:', { user, currentUserId, roomId }) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) @@ -90,11 +90,12 @@ const ChatRoomPage = () => { // 기존 메시지 목록 조회 (초기 로드용) const fetchMessages = useCallback(async () => { try { - const response = await messageService.getList(roomId, {limit: 50}) + const response = await messageService.getList(roomId, { limit: 50 }) const transformedMessages = (response.messages || []).map((msg, index) => ({ id: msg.messageId || `msg-${index}-${Date.now()}`, content: msg.content, userId: msg.userId, + nickname: msg.nickname, messageType: msg.messageType, createdAt: new Date(msg.createdAt), isOwn: msg.userId === currentUserId, @@ -122,12 +123,12 @@ const ChatRoomPage = () => { // WebSocket 연결 (별도 effect) useEffect(() => { - console.log('[ChatRoomPage] WebSocket effect triggered:', {roomId, currentUserId, isConnected}) + console.log('[ChatRoomPage] WebSocket effect triggered:', { roomId, currentUserId, isConnected }) if (currentUserId && roomId) { - console.log('[ChatRoomPage] Connecting WebSocket...', {roomId, currentUserId}) + console.log('[ChatRoomPage] Connecting WebSocket...', { roomId, currentUserId }) wsConnect() } else { - console.log('[ChatRoomPage] Missing required values:', {roomId, currentUserId}) + console.log('[ChatRoomPage] Missing required values:', { roomId, currentUserId }) } return () => { @@ -138,7 +139,7 @@ const ChatRoomPage = () => { // 스크롤 맨 아래로 const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } useEffect(() => { @@ -161,7 +162,7 @@ const ChatRoomPage = () => { setShowCommandAutocomplete(false) // 명령어 파싱 - const {isCommand, command, args} = parseCommand(messageContent) + const { isCommand, command, args } = parseCommand(messageContent) // /leave 명령어는 클라이언트에서 직접 처리 if (isCommand && command === '/leave') { @@ -293,23 +294,23 @@ const ChatRoomPage = () => { if (loading) { return ( - - + + ) } return ( - + {/* 헤더 */} - navigate('/freetalk/people')} sx={{mr: 1}}> - + navigate('/freetalk/people')} sx={{ mr: 1 }}> + - - - + + + {room?.name || '채팅방'} {/* 연결 상태 표시 */} @@ -320,7 +321,7 @@ const ChatRoomPage = () => { }} /> - + {room?.level && ( { - + - + {/* 에러 메시지 */} {error && ( - + {error} )} @@ -370,7 +371,7 @@ const ChatRoomPage = () => { }} > {messages.length === 0 ? ( - + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! @@ -380,7 +381,7 @@ const ChatRoomPage = () => { // 투표 메시지 if (message.messageType === MessageType.POLL_CREATE) { return ( - + { // 시스템 명령어 메시지 if (message.messageType === MessageType.SYSTEM_COMMAND) { return ( - + ) } @@ -411,7 +412,7 @@ const ChatRoomPage = () => { > {/* 시스템 메시지 */} {message.isSystem ? ( - + {message.content} @@ -420,8 +421,8 @@ const ChatRoomPage = () => { <> {/* 아바타 (상대방만) */} {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + {(message.nickname || message.userId)?.charAt(0)?.toUpperCase() || 'U'} )} @@ -435,13 +436,13 @@ const ChatRoomPage = () => { > {/* 사용자 이름 (상대방만) */} {!message.isOwn && ( - - {message.userId} + + {message.nickname || message.userId} )} {/* 메시지 버블 */} - + {message.isOwn && ( {formatTime(message.createdAt)} @@ -463,23 +464,23 @@ const ChatRoomPage = () => { opacity: message.isPending ? 0.7 : 1, }} > - + {message.content} {!message.isOwn && ( - + handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.5}} + sx={{ p: 0.5 }} > {playingTTS === message.id ? ( - + ) : ( - + )} @@ -495,7 +496,7 @@ const ChatRoomPage = () => { ) }) )} -
+
{/* 입력 영역 */} @@ -539,11 +540,11 @@ const ChatRoomPage = () => { sx={{ bgcolor: 'primary.main', color: 'white', - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - + From 640fdf219b611e32fd03ee5fef7b237958cf9cbe Mon Sep 17 00:00:00 2001 From: hye-inA Date: Sun, 25 Jan 2026 18:46:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor=20:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=ED=94=84=EB=A1=9C=ED=95=84=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EB=A9=94=EB=89=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 11 ++ src/aws-config.js | 4 +- src/domains/profile/hooks/useProfile.js | 35 ++++++ .../profile/services/profileService.js | 39 ++++++ src/domains/profile/store/profileSlice.js | 114 ++++++++++++++++++ src/layouts/MainLayout/Header/index.jsx | 82 +++++++------ src/store/index.js | 5 +- 7 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 src/domains/profile/hooks/useProfile.js create mode 100644 src/domains/profile/services/profileService.js create mode 100644 src/domains/profile/store/profileSlice.js diff --git a/src/App.jsx b/src/App.jsx index ab94f58..6896eeb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { useDispatch } from 'react-redux' import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, @@ -43,6 +44,7 @@ import { useSettings } from './contexts/SettingsContext' import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' +import { fetchMyProfile } from "./domains/profile/store/profileSlice"; function ProtectedRoute({ children }) { @@ -1141,8 +1143,17 @@ function NotFound() { } function App() { + const dispatch = useDispatch() + const { isAuthenticated } = useAuth() const { activeRoom, closeChatRoom } = useChat() + useEffect(() => { + if (isAuthenticated) { + // redux로 프로필 정보 API(/users/profile/me) 호출 + dispatch(fetchMyProfile()) + } + }, [isAuthenticated, dispatch]) + const handleRefreshRooms = () => { // Refresh rooms list after leaving a room } diff --git a/src/aws-config.js b/src/aws-config.js index d4d0a9d..6d2b672 100644 --- a/src/aws-config.js +++ b/src/aws-config.js @@ -1,8 +1,8 @@ const awsConfig = { Auth: { Cognito: { - userPoolId: 'ap-northeast-2_ezDwzFCzR', - userPoolClientId: '4ns077jcr1pkue2vvisr6qdpu5', + userPoolId: import.meta.env.VITE_COGNITO_POOL_ID, + userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, loginWith: { email: true, diff --git a/src/domains/profile/hooks/useProfile.js b/src/domains/profile/hooks/useProfile.js new file mode 100644 index 0000000..3e965c4 --- /dev/null +++ b/src/domains/profile/hooks/useProfile.js @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + fetchMyProfile, + updateProfile, + uploadProfileImage, + clearError +} from '../store/profileSlice' + +export const useProfile = () => { + const dispatch = useDispatch() + const { profile, loading, error, updateLoading, imageUploading } = useSelector( + (state) => state.profile + ) + + useEffect(() => { + if (!profile && !loading && !error) { + dispatch(fetchMyProfile()) + } + }, [dispatch, profile, loading, error]) + + return { + profile, + loading, + error, + updateLoading, + imageUploading, + updateProfile: (data) => dispatch(updateProfile(data)).unwrap(), + uploadImage: (file) => dispatch(uploadProfileImage(file)).unwrap(), + clearError: () => dispatch(clearError()), + refetch: () => dispatch(fetchMyProfile()) + } +} + +export default useProfile \ No newline at end of file diff --git a/src/domains/profile/services/profileService.js b/src/domains/profile/services/profileService.js new file mode 100644 index 0000000..c41fcdc --- /dev/null +++ b/src/domains/profile/services/profileService.js @@ -0,0 +1,39 @@ +import api from '../../../api/axios' + +const profileService = { + // 내 프로필 조회 + getMyProfile: async () => { + const response = await api.get('/users/profile/me') + return response.data + }, + + // 프로필 수정 (닉네임, 레벨) + updateProfile: async ({ nickname, level, profileUrl }) => { + const response = await api.put('/users/profile/me', { + nickname, + level, + profileUrl + }) + return response.data + }, + + // 이미지 업로드 URL 발급 + getImageUploadUrl: async (fileName, contentType) => { + const response = await api.post('/users/profile/me/image', { + fileName, + contentType + }) + return response.data + }, + + // S3에 이미지 직접 업로드 + uploadImageToS3: async (uploadUrl, file) => { + await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file + }) + } +} + +export default profileService \ No newline at end of file diff --git a/src/domains/profile/store/profileSlice.js b/src/domains/profile/store/profileSlice.js new file mode 100644 index 0000000..76995ec --- /dev/null +++ b/src/domains/profile/store/profileSlice.js @@ -0,0 +1,114 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import profileService from '../services/profileService' + +// 프로필 조회 +export const fetchMyProfile = createAsyncThunk( + 'profile/fetchMyProfile', + async (_, { rejectWithValue }) => { + try { + const response = await profileService.getMyProfile() + return response.data || response + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.error || error.message || '프로필 조회 실패' + return rejectWithValue({ message, status }) + } + } +) + +// 프로필 수정 +export const updateProfile = createAsyncThunk( + 'profile/updateProfile', + async (data, { rejectWithValue }) => { + try { + const response = await profileService.updateProfile(data) + return response.data + } catch (error) { + return rejectWithValue(error.response?.data?.error || '프로필 수정 실패') + } + } +) + +// 이미지 업로드 +export const uploadProfileImage = createAsyncThunk( + 'profile/uploadImage', + async (file, { dispatch, rejectWithValue }) => { + try { + const urlResponse = await profileService.getImageUploadUrl(file.name, file.type) + const { uploadUrl, imageUrl } = urlResponse.data + + await profileService.uploadImageToS3(uploadUrl, file) + await dispatch(updateProfile({ profileUrl: imageUrl })) + + return imageUrl + } catch (error) { + return rejectWithValue('이미지 업로드 실패') + } + } +) + +const profileSlice = createSlice({ + name: 'profile', + initialState: { + profile: null, + loading: false, + error: null, + updateLoading: false, + imageUploading: false, + authError: false + }, + reducers: { + clearError: (state) => { state.error = null }, + clearProfile: (state) => { state.profile = null } + }, + extraReducers: (builder) => { + builder + // fetchMyProfile + .addCase(fetchMyProfile.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchMyProfile.fulfilled, (state, action) => { + state.loading = false + state.profile = action.payload + }) + .addCase(fetchMyProfile.rejected, (state, action) => { + state.loading = false + state.error = action.payload?.message || action.payload + + const status = action.payload?.status + const message = String(action.payload?.message || action.payload || '') + + if (status === 401 || message.includes('401') || message.includes('인증')) { + state.profile = null + state.authError = true + } + }) + // updateProfile + .addCase(updateProfile.pending, (state) => { + state.updateLoading = true + }) + .addCase(updateProfile.fulfilled, (state, action) => { + state.updateLoading = false + state.profile = action.payload + }) + .addCase(updateProfile.rejected, (state, action) => { + state.updateLoading = false + state.error = action.payload + }) + // uploadProfileImage + .addCase(uploadProfileImage.pending, (state) => { + state.imageUploading = true + }) + .addCase(uploadProfileImage.fulfilled, (state) => { + state.imageUploading = false + }) + .addCase(uploadProfileImage.rejected, (state, action) => { + state.imageUploading = false + state.error = action.payload + }) + } +}) + +export const { clearError, clearProfile } = profileSlice.actions +export default profileSlice.reducer \ No newline at end of file diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f52deba..d4c6583 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -1,5 +1,6 @@ -import {useState} from 'react' -import {useNavigate} from 'react-router-dom' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { AppBar, Avatar, @@ -24,25 +25,26 @@ import { Settings as SettingsIcon, Translate as TranslateIcon, } from '@mui/icons-material' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {useSettings, useTranslation} from '../../../contexts/SettingsContext' -import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' -import {useAuth} from '../../../contexts/AuthContext' -import {useNotificationContext, NotificationMenu} from '../../../domains/notification' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useSettings, useTranslation } from '../../../contexts/SettingsContext' +import { LANGUAGE_LABELS, LANGUAGES } from '../../../i18n/translations' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotificationContext, NotificationMenu } from '../../../domains/notification' -const Header = ({onMenuClick, sidebarOpen}) => { +const Header = ({ onMenuClick, sidebarOpen }) => { const theme = useTheme() const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const {mode, toggleTheme} = useThemeMode() - const {setLanguage, language} = useSettings() - const {t} = useTranslation() + const { mode, toggleTheme } = useThemeMode() + const { setLanguage, language } = useSettings() + const { t } = useTranslation() + const { profile } = useSelector((state) => state.profile) const [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) - const {logout} = useAuth() - const {unreadCount} = useNotificationContext() + const { logout } = useAuth() + const { unreadCount } = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -93,7 +95,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', }} > - + {/* Hamburger menu (mobile) */} {isMobile && ( { }, }} > - + )} @@ -145,7 +147,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { L - + { - + {/* Right side icons */} - + {/* Language selector */} { }, }} > - + {/* Dark mode toggle */} @@ -192,7 +194,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - {mode === 'dark' ? : } + {mode === 'dark' ? : } {/* Notifications */} @@ -219,14 +221,14 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - + {/* Profile */} { fontSize: 14, }} > - U + {profile?.nickname ? profile.nickname.substring(0, 1).toUpperCase() : 'U'} @@ -255,15 +257,15 @@ const Header = ({onMenuClick, sidebarOpen}) => { minWidth: 160, }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + {t('settings.language')} - + {Object.entries(LANGUAGES).map(([key, value]) => ( { boxShadow: '0 10px 40px -10px rgba(0,0,0,0.2)', }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + - {t('header.user')} + {profile?.nickname || t('header.user')} - user@example.com + {profile?.email || 'user@example.com'} - + { handleProfileMenuClose(); navigate('/profile'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.profile')} { handleProfileMenuClose(); navigate('/settings'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.settings')} - + - + {t('nav.logout')} diff --git a/src/store/index.js b/src/store/index.js index 543e164..17e676c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,4 +1,6 @@ -import {configureStore, createSlice} from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' + +import profileReducer from '../domains/profile/store/profileSlice' // 임시 슬라이스 (빈 store 에러 방지) const appSlice = createSlice({ @@ -12,6 +14,7 @@ const appSlice = createSlice({ export const store = configureStore({ reducer: { app: appSlice.reducer, + profile: profileReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({