From d59ea7f8a9e28674814f69455ec96d59ea2f4319 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 6 Jan 2026 16:52:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FEAT]=20Chat=20API=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env.example 추가 (환경변수 템플릿) - VITE_CHAT_API_URL, VITE_TEMP_USER_ID 환경변수 설정 - chatApi axios 인스턴스 생성 - chatRoomService 구현 (create, getList, getDetail, join, leave) - messageService 구현 (send, getList) - voiceService 구현 (synthesize) Closes #38, #39 --- src/api/chatApi.js | 30 +++++++++ src/domains/chat/services/chatService.js | 83 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/api/chatApi.js create mode 100644 src/domains/chat/services/chatService.js diff --git a/src/api/chatApi.js b/src/api/chatApi.js new file mode 100644 index 0000000..73ba2cc --- /dev/null +++ b/src/api/chatApi.js @@ -0,0 +1,30 @@ +import axios from 'axios' + +const chatApi = axios.create({ + baseURL: import.meta.env.VITE_CHAT_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +chatApi.interceptors.request.use( + (config) => { + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +chatApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Chat API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +export default chatApi diff --git a/src/domains/chat/services/chatService.js b/src/domains/chat/services/chatService.js new file mode 100644 index 0000000..9ab3207 --- /dev/null +++ b/src/domains/chat/services/chatService.js @@ -0,0 +1,83 @@ +import chatApi from '../../../api/chatApi' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// 채팅방 API +export const chatRoomService = { + // 채팅방 생성 + create: async (data) => { + return chatApi.post('/chat/rooms', { + ...data, + createdBy: TEMP_USER_ID, + }) + }, + + // 채팅방 목록 조회 + getList: async (params = {}) => { + const { limit = 10, level, joined, cursor } = params + const queryParams = new URLSearchParams() + + queryParams.append('limit', limit) + if (level) queryParams.append('level', level) + if (joined) { + queryParams.append('joined', 'true') + queryParams.append('userId', TEMP_USER_ID) + } + if (cursor) queryParams.append('cursor', cursor) + + return chatApi.get(`/chat/rooms?${queryParams.toString()}`) + }, + + // 채팅방 상세 조회 + getDetail: async (roomId) => { + return chatApi.get(`/chat/rooms/${roomId}`) + }, + + // 채팅방 입장 + join: async (roomId, password) => { + return chatApi.post(`/chat/rooms/${roomId}/join`, { + userId: TEMP_USER_ID, + ...(password && { password }), + }) + }, + + // 채팅방 퇴장 + leave: async (roomId) => { + return chatApi.post(`/chat/rooms/${roomId}/leave`, { + userId: TEMP_USER_ID, + }) + }, +} + +// 메시지 API +export const messageService = { + // 메시지 전송 + send: async (roomId, content, messageType = 'TEXT') => { + return chatApi.post(`/chat/rooms/${roomId}/messages`, { + userId: TEMP_USER_ID, + content, + messageType, + }) + }, + + // 메시지 목록 조회 + getList: async (roomId, params = {}) => { + const { limit = 20, cursor } = params + const queryParams = new URLSearchParams() + + queryParams.append('limit', limit) + if (cursor) queryParams.append('cursor', cursor) + + return chatApi.get(`/chat/rooms/${roomId}/messages?${queryParams.toString()}`) + }, +} + +// 음성 API +export const voiceService = { + // TTS 변환 + synthesize: async (text) => { + return chatApi.post('/chat/voice/synthesize', { text }) + }, +} + +export { TEMP_USER_ID } From 379820f6556013f4aaee8134ddf26ecaa89190a6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 6 Jan 2026 17:01:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[FEAT]=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D/=EC=83=9D=EC=84=B1/=EC=9E=85=EC=9E=A5=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=B1=84=ED=8C=85=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FreetalkPeoplePage: mock 데이터 제거, API 연동 (무한스크롤) - CreateRoomModal: 채팅방 생성 모달 컴포넌트 추가 - ChatRoomPage: 카카오톡 스타일 채팅 UI 구현 - 메시지 조회/전송 API 연동 - TTS 재생 버튼 구현 Closes #40, #41, #42, #43, #44, #45 --- src/App.jsx | 4 + .../freetalk/components/CreateRoomModal.jsx | 188 +++++++++ src/domains/freetalk/pages/ChatRoomPage.jsx | 385 ++++++++++++++++++ .../freetalk/pages/FreetalkPeoplePage.jsx | 307 +++++++++----- 4 files changed, 788 insertions(+), 96 deletions(-) create mode 100644 src/domains/freetalk/components/CreateRoomModal.jsx create mode 100644 src/domains/freetalk/pages/ChatRoomPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 563d904..40435c3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import { } from '@mui/icons-material' import MainLayout from './layouts/MainLayout' import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' +import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' // 임시 대시보드 페이지 function Dashboard() { @@ -271,6 +272,9 @@ function NotFound() { function App() { return ( + {/* 채팅방 페이지 (별도 레이아웃) */} + } /> + {/* MainLayout 적용 라우트 */} }> } /> diff --git a/src/domains/freetalk/components/CreateRoomModal.jsx b/src/domains/freetalk/components/CreateRoomModal.jsx new file mode 100644 index 0000000..c08ce79 --- /dev/null +++ b/src/domains/freetalk/components/CreateRoomModal.jsx @@ -0,0 +1,188 @@ +import { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Switch, + Box, + CircularProgress, + Alert, +} from '@mui/material' +import { chatRoomService } from '../../chat/services/chatService' + +const CreateRoomModal = ({ open, onClose, onSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 6, + isPrivate: false, + password: '', + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleChange = (field) => (e) => { + const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value + setFormData((prev) => ({ + ...prev, + [field]: value, + })) + setError(null) + } + + const handleSubmit = async () => { + if (!formData.name.trim()) { + setError('채팅방 이름을 입력해주세요') + return + } + + if (formData.isPrivate && !formData.password) { + setError('비밀방은 비밀번호가 필요합니다') + return + } + + setLoading(true) + setError(null) + + try { + const payload = { + name: formData.name.trim(), + description: formData.description.trim() || undefined, + level: formData.level, + maxParticipants: formData.maxParticipants, + isPrivate: formData.isPrivate, + ...(formData.isPrivate && { password: formData.password }), + } + + await chatRoomService.create(payload) + handleClose() + if (onSuccess) onSuccess() + } catch (err) { + console.error('Failed to create room:', err) + setError('채팅방 생성에 실패했습니다. 다시 시도해주세요.') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setFormData({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 6, + isPrivate: false, + password: '', + }) + setError(null) + onClose() + } + + return ( + + 새 채팅방 만들기 + + + {error && ( + setError(null)}> + {error} + + )} + + + + + + + 레벨 + + + + + 최대 인원 + + + + + } + label="비밀방으로 설정" + /> + + {formData.isPrivate && ( + + )} + + + + + + + + ) +} + +export default CreateRoomModal diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx new file mode 100644 index 0000000..6a666a6 --- /dev/null +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -0,0 +1,385 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + Box, + Container, + Paper, + Typography, + TextField, + IconButton, + CircularProgress, + Alert, + AppBar, + Toolbar, + Chip, + Avatar, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + Send as SendIcon, + VolumeUp as VolumeUpIcon, + Refresh as RefreshIcon, + ExitToApp as ExitToAppIcon, +} from '@mui/icons-material' +import { chatRoomService, messageService, voiceService, TEMP_USER_ID } from '../../chat/services/chatService' + +const levelColors = { + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, +} + +const ChatRoomPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + + const [room, setRoom] = useState(null) + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState('') + const [loading, setLoading] = useState(true) + const [sendingMessage, setSendingMessage] = useState(false) + const [error, setError] = useState(null) + const [playingTTS, setPlayingTTS] = useState(null) + + // 채팅방 정보 조회 + const fetchRoomDetail = useCallback(async () => { + try { + const response = await chatRoomService.getDetail(roomId) + setRoom({ + id: response.roomId, + name: response.name, + description: response.description, + level: response.level?.toLowerCase() || 'beginner', + currentMembers: response.currentParticipants || 0, + maxMembers: response.maxParticipants || 6, + }) + } catch (err) { + console.error('Failed to fetch room detail:', err) + setError('채팅방 정보를 불러오는데 실패했습니다') + } + }, [roomId]) + + // 메시지 목록 조회 + const fetchMessages = useCallback(async () => { + try { + const response = await messageService.getList(roomId, { limit: 50 }) + const transformedMessages = (response.messages || []).map((msg) => ({ + id: msg.messageId, + content: msg.content, + userId: msg.userId, + messageType: msg.messageType, + createdAt: new Date(msg.createdAt), + isOwn: msg.userId === TEMP_USER_ID, + })) + // 오래된 메시지가 위에 오도록 정렬 + setMessages(transformedMessages.reverse()) + } catch (err) { + console.error('Failed to fetch messages:', err) + setError('메시지를 불러오는데 실패했습니다') + } + }, [roomId]) + + // 초기 로드 + useEffect(() => { + const loadData = async () => { + setLoading(true) + await Promise.all([fetchRoomDetail(), fetchMessages()]) + setLoading(false) + } + loadData() + }, [fetchRoomDetail, fetchMessages]) + + // 스크롤 맨 아래로 + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + // 메시지 전송 + const handleSendMessage = async () => { + if (!newMessage.trim() || sendingMessage) return + + setSendingMessage(true) + const messageContent = newMessage.trim() + setNewMessage('') + + // Optimistic update + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: TEMP_USER_ID, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(roomId, messageContent) + // 전송 성공 시 메시지 새로고침 + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + // 실패 시 임시 메시지 제거 + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } + } + + // 엔터 키 전송 + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // TTS 재생 + const handlePlayTTS = async (messageId, text) => { + if (playingTTS === messageId) return + + setPlayingTTS(messageId) + try { + const response = await voiceService.synthesize(text) + if (response.audioUrl) { + const audio = new Audio(response.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('Failed to play TTS:', err) + setPlayingTTS(null) + } + } + + // 채팅방 퇴장 + const handleLeaveRoom = async () => { + try { + await chatRoomService.leave(roomId) + navigate('/freetalk/people') + } catch (err) { + console.error('Failed to leave room:', err) + setError('채팅방 퇴장에 실패했습니다') + } + } + + // 새로고침 + const handleRefresh = () => { + fetchMessages() + } + + // 시간 포맷 + const formatTime = (date) => { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }).format(date) + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + navigate('/freetalk/people')} sx={{ mr: 1 }}> + + + + + {room?.name || '채팅방'} + + + {room?.level && ( + + )} + + {room?.currentMembers}/{room?.maxMembers}명 + + + + + + + + + + + + + {/* 에러 메시지 */} + {error && ( + setError(null)} sx={{ m: 1 }}> + {error} + + )} + + {/* 메시지 영역 */} + + {messages.length === 0 ? ( + + + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => ( + + {/* 아바타 (상대방만) */} + {!message.isOwn && ( + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + )} + + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.userId} + + )} + + {/* 메시지 버블 */} + + {message.isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id, message.content)} + disabled={playingTTS === message.id} + sx={{ p: 0.5 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + )) + )} +
+ + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + size="small" + multiline + maxRows={3} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 3, + }, + }} + /> + + {sendingMessage ? : } + + + + ) +} + +export default ChatRoomPage diff --git a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx index 53762db..141a5c4 100644 --- a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx +++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Container, @@ -16,14 +16,19 @@ import { DialogContent, DialogActions, Chip, + CircularProgress, + Alert, } from '@mui/material' import { Search as SearchIcon, Add as AddIcon, Lock as LockIcon, People as PeopleIcon, + Refresh as RefreshIcon, } from '@mui/icons-material' import ChatRoomCard from '../components/ChatRoomCard' +import CreateRoomModal from '../components/CreateRoomModal' +import { chatRoomService, TEMP_USER_ID } from '../../chat/services/chatService' const levelColors = { beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, @@ -31,70 +36,6 @@ const levelColors = { advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, } -// 더미 데이터 -const mockRooms = [ - { - id: 1, - name: '영어 일상 대화방', - description: '편하게 일상 영어로 대화해요', - level: 'beginner', - currentMembers: 3, - maxMembers: 6, - lastMessageAt: new Date(Date.now() - 5 * 60 * 1000), - createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), - isPrivate: false, - isJoined: true, - }, - { - id: 2, - name: '비즈니스 영어 연습', - description: '회의, 이메일, 프레젠테이션 영어', - level: 'intermediate', - currentMembers: 4, - maxMembers: 5, - lastMessageAt: new Date(Date.now() - 30 * 60 * 1000), - createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), - isPrivate: true, - isJoined: false, - }, - { - id: 3, - name: '고급 토론방', - description: '시사 이슈로 깊이 있는 토론', - level: 'advanced', - currentMembers: 2, - maxMembers: 4, - lastMessageAt: new Date(Date.now() - 2 * 60 * 60 * 1000), - createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), - isPrivate: true, - isJoined: true, - }, - { - id: 4, - name: '영어 초보 환영', - description: '틀려도 괜찮아요! 함께 성장해요', - level: 'beginner', - currentMembers: 5, - maxMembers: 8, - lastMessageAt: new Date(Date.now() - 10 * 60 * 1000), - createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - isPrivate: false, - isJoined: false, - }, - { - id: 5, - name: '프리토킹 중급반', - description: '자유 주제로 스피킹 연습', - level: 'intermediate', - currentMembers: 3, - maxMembers: 6, - lastMessageAt: new Date(Date.now() - 45 * 60 * 1000), - createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), - isPrivate: false, - isJoined: true, - }, -] - const FreetalkPeoplePage = () => { const navigate = useNavigate() const [searchQuery, setSearchQuery] = useState('') @@ -104,6 +45,138 @@ const FreetalkPeoplePage = () => { const [password, setPassword] = useState('') const [passwordError, setPasswordError] = useState('') + // API 관련 state + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [cursor, setCursor] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [joining, setJoining] = useState(false) + const [createModalOpen, setCreateModalOpen] = useState(false) + const observerRef = useRef(null) + + // API에서 채팅방 데이터를 프론트엔드 형식으로 변환 + const transformRoomData = (apiRoom) => ({ + id: apiRoom.roomId, + name: apiRoom.name, + description: apiRoom.description || '', + level: apiRoom.level?.toLowerCase() || 'beginner', + currentMembers: apiRoom.currentParticipants || 0, + maxMembers: apiRoom.maxParticipants || 6, + lastMessageAt: apiRoom.lastMessageAt ? new Date(apiRoom.lastMessageAt) : null, + createdAt: apiRoom.createdAt ? new Date(apiRoom.createdAt) : new Date(), + isPrivate: apiRoom.isPrivate || false, + isJoined: apiRoom.isJoined || false, + }) + + // 채팅방 목록 조회 + const fetchRooms = useCallback(async (isLoadMore = false) => { + if (loading) return + + setLoading(true) + setError(null) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && { level: levelFilter.toUpperCase() }), + ...(levelFilter === 'joined' && { joined: true }), + ...(isLoadMore && cursor && { cursor }), + } + + const response = await chatRoomService.getList(params) + const transformedRooms = (response.rooms || []).map(transformRoomData) + + if (isLoadMore) { + setRooms((prev) => [...prev, ...transformedRooms]) + } else { + setRooms(transformedRooms) + } + + setCursor(response.nextCursor || null) + setHasMore(!!response.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [levelFilter, cursor, loading]) + + // 초기 로드 및 필터 변경 시 재조회 + useEffect(() => { + const loadRooms = async () => { + setLoading(true) + setError(null) + setCursor(null) + setHasMore(true) + setRooms([]) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && { level: levelFilter.toUpperCase() }), + ...(levelFilter === 'joined' && { joined: true }), + } + + const response = await chatRoomService.getList(params) + const transformedRooms = (response.rooms || []).map(transformRoomData) + setRooms(transformedRooms) + setCursor(response.nextCursor || null) + setHasMore(!!response.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + } + + loadRooms() + }, [levelFilter]) + + // 무한 스크롤 + const lastRoomRef = useCallback((node) => { + if (loading) return + if (observerRef.current) observerRef.current.disconnect() + + observerRef.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + fetchRooms(true) + } + }) + + if (node) observerRef.current.observe(node) + }, [loading, hasMore, fetchRooms]) + + // 새로고침 + const handleRefresh = async () => { + setLoading(true) + setError(null) + setCursor(null) + setHasMore(true) + setRooms([]) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && { level: levelFilter.toUpperCase() }), + ...(levelFilter === 'joined' && { joined: true }), + } + + const response = await chatRoomService.getList(params) + const transformedRooms = (response.rooms || []).map(transformRoomData) + setRooms(transformedRooms) + setCursor(response.nextCursor || null) + setHasMore(!!response.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + } + const handleLevelChange = (event, newLevel) => { if (newLevel !== null) { setLevelFilter(newLevel) @@ -124,32 +197,36 @@ const FreetalkPeoplePage = () => { setPasswordError('') } - const handleEnterRoom = () => { - if (selectedRoom?.isPrivate) { - // 비밀번호 검증 (더미: 1234) - if (password === '1234') { + const handleEnterRoom = async () => { + if (!selectedRoom) return + + setJoining(true) + setPasswordError('') + + try { + await chatRoomService.join(selectedRoom.id, selectedRoom.isPrivate ? password : undefined) + navigate(`/freetalk/people/room/${selectedRoom.id}`) + handleCloseModal() + } catch (err) { + console.error('Failed to join room:', err) + if (err.response?.status === 401 || err.response?.data?.message?.includes('password')) { + setPasswordError('비밀번호가 일치하지 않습니다') + } else if (err.response?.status === 409) { + // 이미 참여중인 경우 바로 입장 navigate(`/freetalk/people/room/${selectedRoom.id}`) handleCloseModal() } else { - setPasswordError('비밀번호가 일치하지 않습니다') + setPasswordError('입장에 실패했습니다. 다시 시도해주세요.') } - } else { - navigate(`/freetalk/people/room/${selectedRoom.id}`) - handleCloseModal() + } finally { + setJoining(false) } } - const filteredRooms = mockRooms.filter((room) => { + // 클라이언트 사이드 검색 필터 + const filteredRooms = rooms.filter((room) => { const matchesSearch = room.name.toLowerCase().includes(searchQuery.toLowerCase()) - let matchesLevel = false - if (levelFilter === 'all') { - matchesLevel = true - } else if (levelFilter === 'joined') { - matchesLevel = room.isJoined - } else { - matchesLevel = room.level === levelFilter - } - return matchesSearch && matchesLevel + return matchesSearch }) return ( @@ -165,7 +242,7 @@ const FreetalkPeoplePage = () => { {/* 필터 영역 */} - + {/* 검색 */} { 고급 참여중 + + {/* 새로고침 버튼 */} + + {/* 에러 메시지 */} + {error && ( + setError(null)}> + {error} + + )} + {/* 채팅방 목록 */} - {filteredRooms.map((room) => ( - + {filteredRooms.map((room, index) => ( + ))} + {/* 로딩 인디케이터 */} + {loading && ( + + + + )} + {/* 빈 상태 */} - {filteredRooms.length === 0 && ( + {!loading && filteredRooms.length === 0 && ( - 검색 결과가 없습니다 + {error ? '데이터를 불러올 수 없습니다' : '채팅방이 없습니다'} - 다른 키워드로 검색하거나 새 채팅방을 만들어보세요 + {error ? '새로고침 버튼을 눌러 다시 시도해주세요' : '새 채팅방을 만들어보세요'} )} @@ -227,14 +337,18 @@ const FreetalkPeoplePage = () => { bottom: 24, right: 24, }} - onClick={() => { - // TODO: 채팅방 생성 모달 - console.log('Create new room') - }} + onClick={() => setCreateModalOpen(true)} > + {/* 채팅방 생성 모달 */} + setCreateModalOpen(false)} + onSuccess={handleRefresh} + /> + {/* 입장 모달 */} {selectedRoom && ( @@ -296,15 +410,16 @@ const FreetalkPeoplePage = () => { )} - From 4d1cc2ac215d3aeb70065a84e0909e402d0c4b22 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 6 Jan 2026 17:49:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FEAT]=20=EC=A0=84=EC=97=AD=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=AA=A8=EB=8B=AC=20=EB=B0=8F=20TTS=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatContext를 통한 전역 채팅 상태 관리 - 드래그 가능한 플로팅 채팅 모달 구현 - 최소화/최대화 기능 추가 - 모든 메시지 TTS 재생 기능 - 페이지 이동 시에도 채팅 유지 - Redux store 초기화 오류 수정 - 스크롤바 hover 시에만 표시 --- src/App.jsx | 52 +- src/contexts/ChatContext.jsx | 29 ++ .../freetalk/components/ChatRoomModal.jsx | 475 ++++++++++++++++++ .../freetalk/pages/FreetalkPeoplePage.jsx | 62 ++- src/main.jsx | 5 +- src/store/index.js | 13 +- 6 files changed, 591 insertions(+), 45 deletions(-) create mode 100644 src/contexts/ChatContext.jsx create mode 100644 src/domains/freetalk/components/ChatRoomModal.jsx diff --git a/src/App.jsx b/src/App.jsx index 40435c3..9a21099 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,8 @@ import { import MainLayout from './layouts/MainLayout' import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' +import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' +import { useChat } from './contexts/ChatContext' // 임시 대시보드 페이지 function Dashboard() { @@ -270,26 +272,42 @@ function NotFound() { } function App() { + const { activeRoom, closeChatRoom } = useChat() + + const handleRefreshRooms = () => { + // 채팅방 퇴장 후 목록 새로고침 (페이지에서 처리) + } + return ( - - {/* 채팅방 페이지 (별도 레이아웃) */} - } /> + <> + + {/* 채팅방 페이지 (별도 레이아웃) */} + } /> + + {/* MainLayout 적용 라우트 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* MainLayout 적용 라우트 */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* 404 */} + } /> + - {/* 404 */} - } /> - + {/* 전역 채팅 모달 */} + + ) } diff --git a/src/contexts/ChatContext.jsx b/src/contexts/ChatContext.jsx new file mode 100644 index 0000000..edf5be6 --- /dev/null +++ b/src/contexts/ChatContext.jsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useState, useCallback } from 'react' + +const ChatContext = createContext(null) + +export const ChatProvider = ({ children }) => { + const [activeRoom, setActiveRoom] = useState(null) + + const openChatRoom = useCallback((room) => { + setActiveRoom(room) + }, []) + + const closeChatRoom = useCallback(() => { + setActiveRoom(null) + }, []) + + return ( + + {children} + + ) +} + +export const useChat = () => { + const context = useContext(ChatContext) + if (!context) { + throw new Error('useChat must be used within a ChatProvider') + } + return context +} diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx new file mode 100644 index 0000000..aa7f209 --- /dev/null +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -0,0 +1,475 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { + Box, + Typography, + TextField, + IconButton, + CircularProgress, + Alert, + Chip, + Avatar, + Paper, + Fade, +} from '@mui/material' +import { + Close as CloseIcon, + Send as SendIcon, + VolumeUp as VolumeUpIcon, + Refresh as RefreshIcon, + ExitToApp as ExitToAppIcon, + Minimize as MinimizeIcon, + OpenInFull as MaximizeIcon, +} from '@mui/icons-material' +import { chatRoomService, messageService, voiceService, TEMP_USER_ID } from '../../chat/services/chatService' + +const levelColors = { + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, +} + +const ChatRoomModal = ({ open, onClose, room, onLeave }) => { + const messagesEndRef = useRef(null) + const dragRef = useRef(null) + + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState('') + const [loading, setLoading] = useState(true) + const [sendingMessage, setSendingMessage] = useState(false) + const [error, setError] = useState(null) + const [playingTTS, setPlayingTTS] = useState(null) + const [minimized, setMinimized] = useState(false) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + // 메시지 목록 조회 + const fetchMessages = useCallback(async () => { + if (!room?.id) return + + try { + const response = await messageService.getList(room.id, { limit: 50 }) + const responseData = response.data || response + const transformedMessages = (responseData.messages || []).map((msg) => ({ + id: msg.messageId || msg.pk?.replace('MESSAGE#', ''), + content: msg.content, + userId: msg.userId, + messageType: msg.messageType, + createdAt: new Date(msg.createdAt), + isOwn: msg.userId === TEMP_USER_ID, + })) + setMessages(transformedMessages.reverse()) + } catch (err) { + console.error('Failed to fetch messages:', err) + setError('메시지를 불러오는데 실패했습니다') + } + }, [room?.id]) + + // 초기 로드 + useEffect(() => { + if (open && room?.id) { + setLoading(true) + setMessages([]) + setMinimized(false) + fetchMessages().finally(() => setLoading(false)) + } + }, [open, room?.id, fetchMessages]) + + // 스크롤 맨 아래로 + const scrollToBottom = (instant = false) => { + messagesEndRef.current?.scrollIntoView({ behavior: instant ? 'instant' : 'smooth' }) + } + + // 메시지 로드 완료 후 스크롤 + useEffect(() => { + if (!loading && messages.length > 0) { + // 처음 로드 시 즉시 스크롤 + setTimeout(() => scrollToBottom(true), 100) + } + }, [loading]) + + // 새 메시지 추가 시 부드럽게 스크롤 + useEffect(() => { + if (messages.length > 0 && !loading) { + scrollToBottom(false) + } + }, [messages.length]) + + // 드래그 핸들러 + const handleMouseDown = (e) => { + // 버튼, 입력창, 슬라이더, 팝오버 클릭 시 드래그 방지 + if ( + e.target.closest('button') || + e.target.closest('input') || + e.target.closest('.MuiSlider-root') || + e.target.closest('.MuiPopover-root') + ) return + setIsDragging(true) + const rect = dragRef.current?.getBoundingClientRect() + setDragOffset({ + x: e.clientX - (rect?.left || 0), + y: e.clientY - (rect?.top || 0), + }) + } + + useEffect(() => { + const handleMouseMove = (e) => { + if (!isDragging) return + setPosition({ + x: e.clientX - dragOffset.x, + y: e.clientY - dragOffset.y, + }) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [isDragging, dragOffset]) + + // 메시지 전송 + const handleSendMessage = async () => { + if (!newMessage.trim() || sendingMessage) return + + setSendingMessage(true) + const messageContent = newMessage.trim() + setNewMessage('') + + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: TEMP_USER_ID, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(room.id, messageContent) + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // TTS 재생 (모든 메시지에서 가능) + const handlePlayTTS = async (messageId, text) => { + if (playingTTS === messageId) return + + setPlayingTTS(messageId) + try { + const response = await voiceService.synthesize(text) + const responseData = response.data || response + if (responseData.audioUrl) { + const audio = new Audio(responseData.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('Failed to play TTS:', err) + setPlayingTTS(null) + } + } + + // 채팅방 퇴장 + const handleLeaveRoom = async () => { + try { + await chatRoomService.leave(room.id) + onLeave?.() + onClose() + } catch (err) { + console.error('Failed to leave room:', err) + setError('채팅방 퇴장에 실패했습니다') + } + } + + const formatTime = (date) => { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }).format(date) + } + + if (!open) return null + + return ( + + + {/* 헤더 - 드래그 가능 */} + + + + {room?.name || '채팅방'} + + {room?.level && ( + + )} + + + + + + setMinimized(!minimized)} sx={{ color: 'white' }} title={minimized ? '최대화' : '최소화'}> + {minimized ? : } + + + + + + + + + + + {!minimized && ( + <> + {/* 에러 메시지 */} + {error && ( + setError(null)} sx={{ borderRadius: 0 }}> + {error} + + )} + + {/* 메시지 영역 */} + {loading ? ( + + + + ) : ( + + {messages.length === 0 ? ( + + + 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => ( + + {!message.isOwn && ( + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + )} + + + {!message.isOwn && ( + + {message.userId} + + )} + + + {message.isOwn && ( + <> + handlePlayTTS(message.id, message.content)} + disabled={playingTTS === message.id} + sx={{ p: 0.25 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id, message.content)} + disabled={playingTTS === message.id} + sx={{ p: 0.25 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + )) + )} +
+ + )} + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + size="small" + multiline + maxRows={2} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + fontSize: '0.85rem', + }, + '& .MuiOutlinedInput-input': { + py: 0.75, + }, + }} + /> + + {sendingMessage ? : } + + + + )} + + + ) +} + +export default ChatRoomModal diff --git a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx index 141a5c4..8b7c30c 100644 --- a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx +++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { useNavigate } from 'react-router-dom' import { Container, Box, @@ -29,6 +28,7 @@ import { import ChatRoomCard from '../components/ChatRoomCard' import CreateRoomModal from '../components/CreateRoomModal' import { chatRoomService, TEMP_USER_ID } from '../../chat/services/chatService' +import { useChat } from '../../../contexts/ChatContext' const levelColors = { beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, @@ -37,7 +37,7 @@ const levelColors = { } const FreetalkPeoplePage = () => { - const navigate = useNavigate() + const { openChatRoom } = useChat() const [searchQuery, setSearchQuery] = useState('') const [levelFilter, setLevelFilter] = useState('all') const [selectedRoom, setSelectedRoom] = useState(null) @@ -56,18 +56,25 @@ const FreetalkPeoplePage = () => { const observerRef = useRef(null) // API에서 채팅방 데이터를 프론트엔드 형식으로 변환 - const transformRoomData = (apiRoom) => ({ - id: apiRoom.roomId, - name: apiRoom.name, - description: apiRoom.description || '', - level: apiRoom.level?.toLowerCase() || 'beginner', - currentMembers: apiRoom.currentParticipants || 0, - maxMembers: apiRoom.maxParticipants || 6, - lastMessageAt: apiRoom.lastMessageAt ? new Date(apiRoom.lastMessageAt) : null, - createdAt: apiRoom.createdAt ? new Date(apiRoom.createdAt) : new Date(), - isPrivate: apiRoom.isPrivate || false, - isJoined: apiRoom.isJoined || false, - }) + const transformRoomData = (apiRoom) => { + // pk가 "ROOM#uuid" 형식이므로 ID 추출 + const roomId = apiRoom.pk?.replace('ROOM#', '') || apiRoom.roomId + // 참여자 수 필드명 여러 가지 체크 + const currentMembers = apiRoom.currentParticipants || apiRoom.participantCount || apiRoom.memberCount || apiRoom.currentMembers || 0 + const maxMembers = apiRoom.maxParticipants || apiRoom.maxMembers || 6 + return { + id: roomId, + name: apiRoom.name || '채팅방', + description: apiRoom.description || '', + level: (apiRoom.level || 'beginner').toLowerCase(), + currentMembers, + maxMembers, + lastMessageAt: apiRoom.lastMessageAt ? new Date(apiRoom.lastMessageAt) : null, + createdAt: apiRoom.createdAt ? new Date(apiRoom.createdAt) : new Date(), + isPrivate: apiRoom.isPrivate || false, + isJoined: apiRoom.isJoined || false, + } + } // 채팅방 목록 조회 const fetchRooms = useCallback(async (isLoadMore = false) => { @@ -85,7 +92,9 @@ const FreetalkPeoplePage = () => { } const response = await chatRoomService.getList(params) - const transformedRooms = (response.rooms || []).map(transformRoomData) + // API 응답: { success, message, data: { rooms, hasMore, nextCursor } } + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) if (isLoadMore) { setRooms((prev) => [...prev, ...transformedRooms]) @@ -93,8 +102,8 @@ const FreetalkPeoplePage = () => { setRooms(transformedRooms) } - setCursor(response.nextCursor || null) - setHasMore(!!response.nextCursor) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) } catch (err) { console.error('Failed to fetch rooms:', err) setError('채팅방 목록을 불러오는데 실패했습니다') @@ -120,10 +129,11 @@ const FreetalkPeoplePage = () => { } const response = await chatRoomService.getList(params) - const transformedRooms = (response.rooms || []).map(transformRoomData) + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) setRooms(transformedRooms) - setCursor(response.nextCursor || null) - setHasMore(!!response.nextCursor) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) } catch (err) { console.error('Failed to fetch rooms:', err) setError('채팅방 목록을 불러오는데 실패했습니다') @@ -165,10 +175,11 @@ const FreetalkPeoplePage = () => { } const response = await chatRoomService.getList(params) - const transformedRooms = (response.rooms || []).map(transformRoomData) + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) setRooms(transformedRooms) - setCursor(response.nextCursor || null) - setHasMore(!!response.nextCursor) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) } catch (err) { console.error('Failed to fetch rooms:', err) setError('채팅방 목록을 불러오는데 실패했습니다') @@ -205,7 +216,8 @@ const FreetalkPeoplePage = () => { try { await chatRoomService.join(selectedRoom.id, selectedRoom.isPrivate ? password : undefined) - navigate(`/freetalk/people/room/${selectedRoom.id}`) + // 전역 채팅 모달 열기 + openChatRoom(selectedRoom) handleCloseModal() } catch (err) { console.error('Failed to join room:', err) @@ -213,7 +225,7 @@ const FreetalkPeoplePage = () => { setPasswordError('비밀번호가 일치하지 않습니다') } else if (err.response?.status === 409) { // 이미 참여중인 경우 바로 입장 - navigate(`/freetalk/people/room/${selectedRoom.id}`) + openChatRoom(selectedRoom) handleCloseModal() } else { setPasswordError('입장에 실패했습니다. 다시 시도해주세요.') diff --git a/src/main.jsx b/src/main.jsx index 847cc86..d6dd98d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { store } from './store' import { ThemeProvider } from './contexts/ThemeContext' +import { ChatProvider } from './contexts/ChatContext' import './index.css' createRoot(document.getElementById('root')).render( @@ -12,7 +13,9 @@ createRoot(document.getElementById('root')).render( - + + + diff --git a/src/store/index.js b/src/store/index.js index 14c2785..0228fab 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,8 +1,17 @@ -import { configureStore } from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' + +// 임시 슬라이스 (빈 store 에러 방지) +const appSlice = createSlice({ + name: 'app', + initialState: { + initialized: true, + }, + reducers: {}, +}) export const store = configureStore({ reducer: { - // 슬라이스들 추가 + app: appSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({