diff --git a/src/App.jsx b/src/App.jsx
index 563d904..9a21099 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -12,6 +12,9 @@ import {
} from '@mui/icons-material'
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() {
@@ -269,23 +272,42 @@ function NotFound() {
}
function App() {
+ const { activeRoom, closeChatRoom } = useChat()
+
+ const handleRefreshRooms = () => {
+ // 채팅방 퇴장 후 목록 새로고침 (페이지에서 처리)
+ }
+
return (
-
- {/* MainLayout 적용 라우트 */}
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+ <>
+
+ {/* 채팅방 페이지 (별도 레이아웃) */}
+ } />
+
+ {/* MainLayout 적용 라우트 */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* 404 */}
+ } />
+
- {/* 404 */}
- } />
-
+ {/* 전역 채팅 모달 */}
+
+ >
)
}
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/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/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 }
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/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 (
+
+ )
+}
+
+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..8b7c30c 100644
--- a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx
+++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx
@@ -1,5 +1,4 @@
-import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { useState, useEffect, useCallback, useRef } from 'react'
import {
Container,
Box,
@@ -16,14 +15,20 @@ 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'
+import { useChat } from '../../../contexts/ChatContext'
const levelColors = {
beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' },
@@ -31,72 +36,8 @@ 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 { openChatRoom } = useChat()
const [searchQuery, setSearchQuery] = useState('')
const [levelFilter, setLevelFilter] = useState('all')
const [selectedRoom, setSelectedRoom] = useState(null)
@@ -104,6 +45,149 @@ 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) => {
+ // 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) => {
+ 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)
+ // API 응답: { success, message, data: { rooms, hasMore, nextCursor } }
+ const responseData = response.data || response
+ const transformedRooms = (responseData.rooms || []).map(transformRoomData)
+
+ if (isLoadMore) {
+ setRooms((prev) => [...prev, ...transformedRooms])
+ } else {
+ setRooms(transformedRooms)
+ }
+
+ setCursor(responseData.nextCursor || null)
+ setHasMore(!!responseData.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 responseData = response.data || response
+ const transformedRooms = (responseData.rooms || []).map(transformRoomData)
+ setRooms(transformedRooms)
+ setCursor(responseData.nextCursor || null)
+ setHasMore(!!responseData.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 responseData = response.data || response
+ const transformedRooms = (responseData.rooms || []).map(transformRoomData)
+ setRooms(transformedRooms)
+ setCursor(responseData.nextCursor || null)
+ setHasMore(!!responseData.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 +208,37 @@ const FreetalkPeoplePage = () => {
setPasswordError('')
}
- const handleEnterRoom = () => {
- if (selectedRoom?.isPrivate) {
- // 비밀번호 검증 (더미: 1234)
- if (password === '1234') {
- navigate(`/freetalk/people/room/${selectedRoom.id}`)
+ const handleEnterRoom = async () => {
+ if (!selectedRoom) return
+
+ setJoining(true)
+ setPasswordError('')
+
+ try {
+ await chatRoomService.join(selectedRoom.id, selectedRoom.isPrivate ? password : undefined)
+ // 전역 채팅 모달 열기
+ openChatRoom(selectedRoom)
+ 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) {
+ // 이미 참여중인 경우 바로 입장
+ openChatRoom(selectedRoom)
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 +254,7 @@ const FreetalkPeoplePage = () => {
{/* 필터 영역 */}
-
+
{/* 검색 */}
{
고급
참여중
+
+ {/* 새로고침 버튼 */}
+ }
+ onClick={handleRefresh}
+ disabled={loading}
+ >
+ 새로고침
+
+ {/* 에러 메시지 */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
{/* 채팅방 목록 */}
- {filteredRooms.map((room) => (
-
+ {filteredRooms.map((room, index) => (
+
))}
+ {/* 로딩 인디케이터 */}
+ {loading && (
+
+
+
+ )}
+
{/* 빈 상태 */}
- {filteredRooms.length === 0 && (
+ {!loading && filteredRooms.length === 0 && (
- 검색 결과가 없습니다
+ {error ? '데이터를 불러올 수 없습니다' : '채팅방이 없습니다'}
- 다른 키워드로 검색하거나 새 채팅방을 만들어보세요
+ {error ? '새로고침 버튼을 눌러 다시 시도해주세요' : '새 채팅방을 만들어보세요'}
)}
@@ -227,14 +349,18 @@ const FreetalkPeoplePage = () => {
bottom: 24,
right: 24,
}}
- onClick={() => {
- // TODO: 채팅방 생성 모달
- console.log('Create new room')
- }}
+ onClick={() => setCreateModalOpen(true)}
>
+ {/* 채팅방 생성 모달 */}
+ setCreateModalOpen(false)}
+ onSuccess={handleRefresh}
+ />
+
{/* 입장 모달 */}