From 3cfeb1520b0b1f1108d1dc8a62e3c38932401d5f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 11:55:03 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[FEAT]=20=EC=B1=84=ED=8C=85=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅 명령어 타입 정의 (chatCommandTypes.js) - CommandAutocomplete 컴포넌트 (/ 입력 시 명령어 자동완성) - PollCard, PollResultBar 컴포넌트 (투표 UI) - SystemCommandMessage 컴포넌트 (주사위, 동전, 랜덤 등) - WebSocket 서비스에 새 메시지 타입 핸들링 추가 - ChatRoomPage에 명령어 처리 로직 통합 지원 명령어: - /help, /members, /leave, /clear - /dice, /coin, /random - /poll, /vote, /endpoll --- .../components/CommandAutocomplete.jsx | 150 +++++++++++ src/domains/freetalk/components/PollCard.jsx | 175 ++++++++++++ .../freetalk/components/PollResultBar.jsx | 57 ++++ .../components/SystemCommandMessage.jsx | 196 ++++++++++++++ .../freetalk/hooks/useChatWebSocket.js | 90 +++++++ src/domains/freetalk/pages/ChatRoomPage.jsx | 255 ++++++++++++------ .../freetalk/services/chatWebSocketService.js | 29 +- .../freetalk/types/chatCommandTypes.js | 219 +++++++++++++++ 8 files changed, 1084 insertions(+), 87 deletions(-) create mode 100644 src/domains/freetalk/components/CommandAutocomplete.jsx create mode 100644 src/domains/freetalk/components/PollCard.jsx create mode 100644 src/domains/freetalk/components/PollResultBar.jsx create mode 100644 src/domains/freetalk/components/SystemCommandMessage.jsx create mode 100644 src/domains/freetalk/types/chatCommandTypes.js diff --git a/src/domains/freetalk/components/CommandAutocomplete.jsx b/src/domains/freetalk/components/CommandAutocomplete.jsx new file mode 100644 index 0000000..09b83e3 --- /dev/null +++ b/src/domains/freetalk/components/CommandAutocomplete.jsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { Box, Paper, Typography, List, ListItem, ListItemButton, ListItemText } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { searchCommands } from '../types/chatCommandTypes' + +/** + * 채팅 명령어 자동완성 컴포넌트 + * 사용자가 "/"를 입력하면 사용 가능한 명령어 목록을 표시합니다. + * + * @param {Object} props + * @param {string} props.input - 현재 입력 값 + * @param {function} props.onSelect - 명령어 선택 시 호출되는 콜백 + * @param {boolean} props.show - 표시 여부 + * @returns {JSX.Element|null} + */ +const CommandAutocomplete = ({ input, onSelect, show }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedIndex, setSelectedIndex] = useState(0) + const [filteredCommands, setFilteredCommands] = useState([]) + + // 입력값에 따라 명령어 필터링 + useEffect(() => { + if (!show || !input.startsWith('/')) { + setFilteredCommands([]) + setSelectedIndex(0) + return + } + + const commands = searchCommands(input) + setFilteredCommands(commands) + setSelectedIndex(0) + }, [input, show]) + + // 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (e) => { + if (!show || filteredCommands.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % filteredCommands.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) + } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) { + e.preventDefault() + onSelect(filteredCommands[selectedIndex].command) + } else if (e.key === 'Escape') { + onSelect('') + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [show, filteredCommands, selectedIndex, onSelect]) + + // 표시할 항목이 없으면 렌더링하지 않음 + if (!show || filteredCommands.length === 0) { + return null + } + + return ( + + + + 사용 가능한 명령어 ({filteredCommands.length}) + + + + + {filteredCommands.map((cmd, index) => ( + + onSelect(cmd.command)} + sx={{ + py: 1.5, + px: 2, + '&.Mui-selected': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.2)' : 'rgba(254, 229, 0, 0.3)', + '&:hover': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.25)' : 'rgba(254, 229, 0, 0.4)', + }, + }, + '&:hover': { + bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', + }, + }} + > + + + {cmd.command} + + + {cmd.usage} + + + } + secondary={ + + {cmd.description} + + } + /> + + + ))} + + + + + 화살표로 선택, Enter로 입력, Esc로 닫기 + + + + ) +} + +export default CommandAutocomplete diff --git a/src/domains/freetalk/components/PollCard.jsx b/src/domains/freetalk/components/PollCard.jsx new file mode 100644 index 0000000..65e5bd6 --- /dev/null +++ b/src/domains/freetalk/components/PollCard.jsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { Box, Button, Card, CardContent, Chip, Typography, IconButton } from '@mui/material' +import { + HowToVote as VoteIcon, + CheckCircle as CheckIcon, + Cancel as CancelIcon, + Person as PersonIcon, +} from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { calculatePollResults } from '../types/chatCommandTypes' +import PollResultBar from './PollResultBar' + +/** + * 투표 카드 컴포넌트 + * 투표 생성, 투표하기, 결과 보기 기능을 제공합니다. + * + * @param {Object} props + * @param {Object} props.poll - 투표 데이터 + * @param {string} props.currentUserId - 현재 사용자 ID + * @param {function} props.onVote - 투표 시 호출되는 콜백 + * @param {function} props.onEndPoll - 투표 종료 시 호출되는 콜백 + * @returns {JSX.Element} + */ +const PollCard = ({ poll, currentUserId, onVote, onEndPoll }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedOption, setSelectedOption] = useState(null) + + const { totalVotes, percentages } = calculatePollResults(poll.options) + const hasVoted = poll.options.some((opt) => opt.voters.includes(currentUserId)) + const isCreator = poll.creatorId === currentUserId + const showResults = !poll.isActive || hasVoted + + const handleVote = (optionId) => { + if (!poll.isActive || hasVoted) return + setSelectedOption(optionId) + onVote?.(poll.pollId, optionId) + } + + const handleEndPoll = () => { + onEndPoll?.(poll.pollId) + } + + return ( + + + {/* 헤더 */} + + + + 투표 + + {poll.isActive ? ( + + ) : ( + + )} + + + {/* 질문 */} + + {poll.question} + + + {/* 옵션 목록 */} + + {poll.options.map((option, index) => { + const percentage = percentages[index] + const isSelected = selectedOption === option.optionId + const userVoted = option.voters.includes(currentUserId) + + return ( + + {showResults ? ( + // 결과 보기 모드 + + + + + {option.text} + + {userVoted && } + + + {option.voteCount}표 ({percentage}%) + + + + + ) : ( + // 투표하기 모드 + + )} + + ) + })} + + + {/* 하단 정보 */} + + + + + 총 {totalVotes}명 참여 + + + + {isCreator && poll.isActive && ( + + + + )} + + + {/* 생성자 정보 */} + + 생성자: {poll.creatorId} + + + + ) +} + +export default PollCard diff --git a/src/domains/freetalk/components/PollResultBar.jsx b/src/domains/freetalk/components/PollResultBar.jsx new file mode 100644 index 0000000..bd46201 --- /dev/null +++ b/src/domains/freetalk/components/PollResultBar.jsx @@ -0,0 +1,57 @@ +import { Box, LinearProgress } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' + +/** + * 투표 결과 진행바 컴포넌트 + * 투표 옵션의 득표율을 시각적으로 표시합니다. + * + * @param {Object} props + * @param {number} props.percentage - 득표율 (0-100) + * @param {boolean} props.isUserVoted - 사용자가 해당 옵션에 투표했는지 여부 + * @returns {JSX.Element} + */ +const PollResultBar = ({ percentage, isUserVoted = false }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + {isUserVoted && ( + + )} + + ) +} + +export default PollResultBar diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx new file mode 100644 index 0000000..12a1423 --- /dev/null +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -0,0 +1,196 @@ +import { Box, Paper, Typography } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { SystemCommandConfig } from '../types/chatCommandTypes' + +/** + * 시스템 명령어 메시지 컴포넌트 + * /dice, /coin, /random, /members, /help 등의 명령어 결과를 표시합니다. + * + * @param {Object} props + * @param {Object} props.data - 시스템 명령어 데이터 + * @param {string} props.data.commandType - 명령어 타입 + * @param {string} props.data.userId - 실행한 사용자 ID + * @param {Object} props.data.result - 명령어 결과 + * @param {string} props.data.displayText - 표시할 텍스트 + * @returns {JSX.Element} + */ +const SystemCommandMessage = ({ data }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + const config = SystemCommandConfig[data.commandType] || SystemCommandConfig.help + const { icon, color, bgColor } = config + + return ( + + + + {/* 아이콘 */} + + {icon} + + + {/* 내용 */} + + + + {data.userId} + + + {data.displayText} + + + + {/* 추가 결과 정보 */} + {renderCommandResult(data.commandType, data.result, isDark)} + + + + + ) +} + +/** + * 명령어 타입별 결과 렌더링 + */ +function renderCommandResult(commandType, result, isDark) { + if (!result) return null + + switch (commandType) { + case 'dice': + return ( + + {result.value} + + ) + + case 'coin': + return ( + + {result.side === 'heads' ? '앞면' : '뒷면'} + + ) + + case 'random': + return ( + + {result.value} + {result.min !== undefined && result.max !== undefined && ( + + ({result.min}-{result.max}) + + )} + + ) + + case 'members': + return result.memberIds ? ( + + + 참여 중인 멤버 ({result.totalCount}명): + + + {result.memberIds.map((memberId) => ( + + {memberId} + + ))} + + + ) : null + + case 'help': + return result.commands ? ( + + + 사용 가능한 명령어: + + + {result.commands.map((cmd) => ( + + {cmd.command} - {cmd.description} + + ))} + + + ) : null + + default: + return null + } +} + +export default SystemCommandMessage diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 7afd702..8aad516 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -77,6 +77,8 @@ export function useChatWebSocket(roomId, userId) { messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, + // 추가 데이터 (투표, 시스템 명령어 등) + data: data.data || data.payload, } setMessages((prev) => { // 중복 메시지 방지 (같은 messageId가 이미 있으면 추가하지 않음) @@ -87,6 +89,94 @@ export function useChatWebSocket(roomId, userId) { }) }, + onPollCreate: (data) => { + console.log('[useChatWebSocket] Poll created:', data) + const pollData = data.data || data + const pollMessage = { + id: `poll-${pollData.pollId}`, + messageType: 'POLL_CREATE', + userId: pollData.creatorId, + createdAt: pollData.createdAt || new Date().toISOString(), + isOwn: pollData.creatorId === userId, + data: pollData, + } + setMessages((prev) => [...prev, pollMessage]) + }, + + onPollVote: (data) => { + console.log('[useChatWebSocket] Poll vote:', data) + const voteData = data.data || data + setMessages((prev) => + prev.map((msg) => { + if (msg.id === `poll-${voteData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + options: voteData.updatedOptions || msg.data.options, + }, + } + } + return msg + }) + ) + }, + + onPollEnd: (data) => { + console.log('[useChatWebSocket] Poll ended:', data) + const endData = data.data || data + setMessages((prev) => + prev.map((msg) => { + if (msg.id === `poll-${endData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + isActive: false, + options: endData.finalResults || msg.data.options, + }, + } + } + return msg + }) + ) + }, + + onClearChat: (data) => { + console.log('[useChatWebSocket] Clear chat:', data) + const clearData = data.data || data + // 특정 사용자의 메시지만 삭제 + setMessages((prev) => + prev.filter((msg) => !clearData.messageIds?.includes(msg.id)) + ) + }, + + onLeaveRoom: (data) => { + console.log('[useChatWebSocket] User left room:', data) + const leaveData = data.data || data + const systemMessage = { + id: `leave-${Date.now()}`, + content: `${leaveData.userId}님이 채팅방을 나갔습니다.`, + messageType: 'SYSTEM', + createdAt: leaveData.leftAt || new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onSystemCommand: (data) => { + console.log('[useChatWebSocket] System command:', data) + const commandData = data.data || data + const commandMessage = { + id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + messageType: 'SYSTEM_COMMAND', + userId: commandData.userId, + createdAt: new Date().toISOString(), + data: commandData, + } + setMessages((prev) => [...prev, commandMessage]) + }, + onGameStart: (data) => { console.log('[useChatWebSocket] Game started - FULL DATA:', JSON.stringify(data, null, 2)) // 실제 게임 데이터 추출 (data.data에 중첩되어 있을 수 있음) diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index cc80e6e..2a220d6 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -25,6 +25,10 @@ import {chatRoomService, messageService, voiceService} from '../../chat/services 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' const levelColors = { beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, @@ -51,6 +55,7 @@ const ChatRoomPage = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) + const [showCommandAutocomplete, setShowCommandAutocomplete] = useState(false) // WebSocket 훅 사용 (채팅방에서는 게임 관련 기능 제외) const { @@ -153,6 +158,19 @@ const ChatRoomPage = () => { const messageContent = newMessage.trim() setNewMessage('') + setShowCommandAutocomplete(false) + + // 명령어 파싱 + const {isCommand, command, args} = parseCommand(messageContent) + + // /leave 명령어는 클라이언트에서 직접 처리 + if (isCommand && command === '/leave') { + handleLeaveRoom() + return + } + + // /clear 명령어는 서버로 전송 (서버에서 처리) + // 나머지 명령어들도 서버로 전송하여 처리 // WebSocket으로 전송 if (isConnected) { @@ -219,6 +237,41 @@ const ChatRoomPage = () => { } } + // 투표하기 + const handleVote = (pollId, optionId) => { + if (isConnected) { + wsSendMessage(`/vote ${pollId} ${optionId}`, 'TEXT') + } + } + + // 투표 종료 + const handleEndPoll = (pollId) => { + if (isConnected) { + wsSendMessage(`/endpoll ${pollId}`, 'TEXT') + } + } + + // 명령어 자동완성 선택 + const handleCommandSelect = (command) => { + if (command) { + setNewMessage(command + ' ') + } + setShowCommandAutocomplete(false) + } + + // 입력값 변경 처리 + const handleInputChange = (e) => { + const value = e.target.value + setNewMessage(value) + + // "/" 입력 시 자동완성 표시 + if (value.startsWith('/') && value.length > 0) { + setShowCommandAutocomplete(true) + } else { + setShowCommandAutocomplete(false) + } + } + // 새로고침 const handleRefresh = () => { fetchMessages() @@ -323,100 +376,124 @@ const ChatRoomPage = () => { ) : ( - messages.map((message) => ( - - {/* 시스템 메시지 */} - {message.isSystem ? ( - - - {message.content} - + messages.map((message) => { + // 투표 메시지 + if (message.messageType === MessageType.POLL_CREATE) { + return ( + + - ) : ( - <> - {/* 아바타 (상대방만) */} - {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} - - )} - - - {/* 사용자 이름 (상대방만) */} + ) + } + + // 시스템 명령어 메시지 + if (message.messageType === MessageType.SYSTEM_COMMAND) { + return ( + + ) + } + + // 일반 메시지 + return ( + + {/* 시스템 메시지 */} + {message.isSystem ? ( + + + {message.content} + + + ) : ( + <> + {/* 아바타 (상대방만) */} {!message.isOwn && ( - - {message.userId} - + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + )} - {/* 메시지 버블 */} - - {message.isOwn && ( - - {formatTime(message.createdAt)} + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.userId} )} - - - {message.content} - - - - {!message.isOwn && ( - - handlePlayTTS(message.id)} - disabled={playingTTS === message.id} - sx={{p: 0.5}} - > - {playingTTS === message.id ? ( - - ) : ( - - )} - + {/* 메시지 버블 */} + + {message.isOwn && ( {formatTime(message.createdAt)} - - )} + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{p: 0.5}} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + - - - )} - - )) + + )} + + ) + }) )}
@@ -430,13 +507,21 @@ const ChatRoomPage = () => { alignItems: 'center', gap: 1, borderRadius: 0, + position: 'relative', }} > + {/* 명령어 자동완성 */} + + setNewMessage(e.target.value)} + onChange={handleInputChange} onKeyPress={handleKeyPress} size="small" multiline diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js index f4d042c..c670394 100644 --- a/src/domains/freetalk/services/chatWebSocketService.js +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -177,8 +177,33 @@ class ChatWebSocketConnection { break case 'system_command': case 'SYSTEM_COMMAND': - // 시스템 명령어 응답 (예: /member, /help 등) - this.callbacks.onMessage?.(data) + // 시스템 명령어 응답 (예: /dice, /coin, /random, /members, /help 등) + this.callbacks.onSystemCommand?.(data) + break + case 'poll_create': + case 'POLL_CREATE': + console.log('[ChatWebSocket] Poll create received:', data) + this.callbacks.onPollCreate?.(data) + break + case 'poll_vote': + case 'POLL_VOTE': + console.log('[ChatWebSocket] Poll vote received:', data) + this.callbacks.onPollVote?.(data) + break + case 'poll_end': + case 'POLL_END': + console.log('[ChatWebSocket] Poll end received:', data) + this.callbacks.onPollEnd?.(data) + break + case 'clear_chat': + case 'CLEAR_CHAT': + console.log('[ChatWebSocket] Clear chat received:', data) + this.callbacks.onClearChat?.(data) + break + case 'leave_room': + case 'LEAVE_ROOM': + console.log('[ChatWebSocket] Leave room received:', data) + this.callbacks.onLeaveRoom?.(data) break case 'error': case 'ERROR': diff --git a/src/domains/freetalk/types/chatCommandTypes.js b/src/domains/freetalk/types/chatCommandTypes.js new file mode 100644 index 0000000..73d87c6 --- /dev/null +++ b/src/domains/freetalk/types/chatCommandTypes.js @@ -0,0 +1,219 @@ +/** + * 채팅 메시지 타입 상수 + * @enum {string} + */ +export const MessageType = { + TEXT: 'TEXT', + IMAGE: 'IMAGE', + VOICE: 'VOICE', + SYSTEM_COMMAND: 'SYSTEM_COMMAND', + POLL_CREATE: 'POLL_CREATE', + POLL_VOTE: 'POLL_VOTE', + POLL_END: 'POLL_END', + CLEAR_CHAT: 'CLEAR_CHAT', + LEAVE_ROOM: 'LEAVE_ROOM', +} + +/** + * 사용 가능한 채팅 명령어 목록 + */ +export const COMMANDS = [ + { + command: '/help', + description: '사용 가능한 명령어 목록 보기', + usage: '/help', + }, + { + command: '/members', + description: '현재 참여 중인 멤버 목록 보기', + usage: '/members', + }, + { + command: '/poll', + description: '투표 생성하기', + usage: '/poll [질문] [옵션1] [옵션2] ...', + }, + { + command: '/vote', + description: '투표하기', + usage: '/vote [투표ID] [옵션번호]', + }, + { + command: '/endpoll', + description: '투표 종료하기', + usage: '/endpoll [투표ID]', + }, + { + command: '/clear', + description: '내 메시지 모두 삭제', + usage: '/clear', + }, + { + command: '/leave', + description: '채팅방 나가기', + usage: '/leave', + }, + { + command: '/dice', + description: '주사위 굴리기 (1-6)', + usage: '/dice', + }, + { + command: '/coin', + description: '동전 던지기 (앞면/뒷면)', + usage: '/coin', + }, + { + command: '/random', + description: '무작위 숫자 생성', + usage: '/random [최소값] [최대값]', + }, +] + +/** + * @typedef {Object} PollOption + * @property {number} optionId - 옵션 ID + * @property {string} text - 옵션 텍스트 + * @property {number} voteCount - 투표 수 + * @property {string[]} voters - 투표한 사용자 ID 목록 + */ + +/** + * @typedef {Object} PollCreateData + * @property {string} pollId - 투표 ID + * @property {string} question - 투표 질문 + * @property {PollOption[]} options - 투표 옵션 목록 + * @property {string} creatorId - 생성자 ID + * @property {string} createdAt - ISO-8601 생성 시간 + * @property {boolean} isActive - 활성 상태 + */ + +/** + * @typedef {Object} PollVoteData + * @property {string} pollId - 투표 ID + * @property {number} optionId - 선택한 옵션 ID + * @property {string} userId - 투표한 사용자 ID + * @property {PollOption[]} updatedOptions - 업데이트된 옵션 목록 + */ + +/** + * @typedef {Object} PollEndData + * @property {string} pollId - 투표 ID + * @property {PollOption[]} finalResults - 최종 결과 + * @property {string} endedBy - 종료한 사용자 ID + * @property {string} endedAt - ISO-8601 종료 시간 + */ + +/** + * @typedef {Object} SystemCommandData + * @property {string} commandType - 명령어 타입 (dice, coin, random, members, help) + * @property {string} userId - 명령어 실행 사용자 ID + * @property {Object} result - 명령어 실행 결과 + * @property {string} displayText - 표시할 텍스트 + */ + +/** + * @typedef {Object} ClearChatData + * @property {string} userId - 삭제 요청 사용자 ID + * @property {string[]} messageIds - 삭제된 메시지 ID 목록 + */ + +/** + * @typedef {Object} LeaveRoomData + * @property {string} userId - 퇴장한 사용자 ID + * @property {string} roomId - 방 ID + * @property {string} leftAt - ISO-8601 퇴장 시간 + */ + +/** + * @typedef {Object} MembersListData + * @property {string[]} memberIds - 멤버 ID 목록 + * @property {number} totalCount - 총 멤버 수 + */ + +/** + * 명령어 파싱 유틸리티 + * @param {string} message - 입력된 메시지 + * @returns {{isCommand: boolean, command: string, args: string[]}} + */ +export function parseCommand(message) { + const trimmed = message.trim() + + if (!trimmed.startsWith('/')) { + return { isCommand: false, command: '', args: [] } + } + + const parts = trimmed.split(/\s+/) + const command = parts[0].toLowerCase() + const args = parts.slice(1) + + return { + isCommand: true, + command, + args, + } +} + +/** + * 명령어가 유효한지 확인 + * @param {string} command - 명령어 (예: '/help') + * @returns {boolean} + */ +export function isValidCommand(command) { + return COMMANDS.some(cmd => cmd.command === command.toLowerCase()) +} + +/** + * 명령어 검색 (자동완성용) + * @param {string} input - 사용자 입력 + * @returns {Array} 일치하는 명령어 목록 + */ +export function searchCommands(input) { + const lowerInput = input.toLowerCase() + return COMMANDS.filter(cmd => cmd.command.startsWith(lowerInput)) +} + +/** + * 투표 결과 계산 + * @param {PollOption[]} options - 투표 옵션 목록 + * @returns {{totalVotes: number, percentages: number[]}} + */ +export function calculatePollResults(options) { + const totalVotes = options.reduce((sum, opt) => sum + opt.voteCount, 0) + const percentages = options.map(opt => + totalVotes > 0 ? Math.round((opt.voteCount / totalVotes) * 100) : 0 + ) + + return { totalVotes, percentages } +} + +/** + * 시스템 명령어 아이콘 및 색상 설정 + */ +export const SystemCommandConfig = { + dice: { + icon: '🎲', + color: '#8b5cf6', + bgColor: '#f5f3ff', + }, + coin: { + icon: '🪙', + color: '#f59e0b', + bgColor: '#fffbeb', + }, + random: { + icon: '🔢', + color: '#06b6d4', + bgColor: '#ecfeff', + }, + members: { + icon: '👥', + color: '#10b981', + bgColor: '#ecfdf5', + }, + help: { + icon: '❓', + color: '#6366f1', + bgColor: '#eef2ff', + }, +} From f8cb7679f5792dfbc3a9f8c1e61012f1742b98e2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:27:56 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[FIX]=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useChatWebSocket: 백엔드 응답을 프론트엔드 형식으로 변환 - SystemCommandMessage: 다양한 응답 구조 처리 - displayText(message) 필드 우선 표시 --- .../components/SystemCommandMessage.jsx | 49 ++++++++++++------- .../freetalk/hooks/useChatWebSocket.js | 16 ++++-- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx index 12a1423..7d82cf2 100644 --- a/src/domains/freetalk/components/SystemCommandMessage.jsx +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -18,9 +18,18 @@ const SystemCommandMessage = ({ data }) => { const { mode } = useThemeMode() const isDark = mode === 'dark' - const config = SystemCommandConfig[data.commandType] || SystemCommandConfig.help + // 명령어 타입 추출 (여러 가능한 필드 확인) + const commandType = data.commandType || data.type || data.raw?.type || 'help' + const config = SystemCommandConfig[commandType] || SystemCommandConfig.help const { icon, color, bgColor } = config + // 표시 텍스트 추출 + const displayText = data.displayText || data.message || data.content || '' + const userId = data.userId || data.nickname || '' + + // 결과 값 추출 + const result = data.result || data.raw || {} + return ( { {/* 내용 */} - - - {data.userId} - + {/* displayText가 있으면 그대로 표시 (백엔드에서 이미 포맷팅됨) */} + {displayText ? ( - {data.displayText} + {displayText} - - - {/* 추가 결과 정보 */} - {renderCommandResult(data.commandType, data.result, isDark)} + ) : ( + <> + + + {userId} + + + {/* 추가 결과 정보 */} + {renderCommandResult(commandType, result, isDark)} + + )} diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 8aad516..37db338 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -167,12 +167,22 @@ export function useChatWebSocket(roomId, userId) { onSystemCommand: (data) => { console.log('[useChatWebSocket] System command:', data) const commandData = data.data || data + // 백엔드 응답 구조를 프론트엔드 형식으로 변환 const commandMessage = { id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, messageType: 'SYSTEM_COMMAND', - userId: commandData.userId, - createdAt: new Date().toISOString(), - data: commandData, + userId: commandData.userId || commandData.nickname || data.userId, + createdAt: data.createdAt || new Date().toISOString(), + data: { + commandType: commandData.type || commandData.commandType || 'help', + userId: commandData.userId || commandData.nickname || data.userId, + displayText: data.message || data.content || commandData.message || '', + result: typeof commandData.result === 'object' + ? commandData.result + : { value: commandData.result }, + // 원본 데이터도 보존 + raw: commandData, + }, } setMessages((prev) => [...prev, commandMessage]) }, From 1d3f4f674e9099538d84f8ae0fddd146edee285e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:29:26 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[FIX]=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20content=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/freetalk/hooks/useChatWebSocket.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 37db338..685c156 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -166,21 +166,23 @@ export function useChatWebSocket(roomId, userId) { onSystemCommand: (data) => { console.log('[useChatWebSocket] System command:', data) - const commandData = data.data || data - // 백엔드 응답 구조를 프론트엔드 형식으로 변환 + const commandData = data.data || {} + // 백엔드 응답 구조: + // - content: 포맷팅된 메시지 (최상위) + // - data.type: 명령어 타입 (dice, coin, etc.) + // - data.result: 결과 값 const commandMessage = { id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, messageType: 'SYSTEM_COMMAND', userId: commandData.userId || commandData.nickname || data.userId, createdAt: data.createdAt || new Date().toISOString(), data: { - commandType: commandData.type || commandData.commandType || 'help', - userId: commandData.userId || commandData.nickname || data.userId, - displayText: data.message || data.content || commandData.message || '', + commandType: commandData.type || 'help', + userId: commandData.userId || commandData.nickname, + displayText: data.content || data.message || '', result: typeof commandData.result === 'object' ? commandData.result : { value: commandData.result }, - // 원본 데이터도 보존 raw: commandData, }, } From 9db32f381244ec9fdb16cdeae6216810bfaaca28 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:30:40 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[DEBUG]=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../freetalk/components/SystemCommandMessage.jsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx index 7d82cf2..8781e56 100644 --- a/src/domains/freetalk/components/SystemCommandMessage.jsx +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -18,17 +18,22 @@ const SystemCommandMessage = ({ data }) => { const { mode } = useThemeMode() const isDark = mode === 'dark' + // 디버깅 + console.log('[SystemCommandMessage] received data:', data) + // 명령어 타입 추출 (여러 가능한 필드 확인) - const commandType = data.commandType || data.type || data.raw?.type || 'help' + const commandType = data?.commandType || data?.type || data?.raw?.type || 'help' const config = SystemCommandConfig[commandType] || SystemCommandConfig.help const { icon, color, bgColor } = config // 표시 텍스트 추출 - const displayText = data.displayText || data.message || data.content || '' - const userId = data.userId || data.nickname || '' + const displayText = data?.displayText || data?.message || data?.content || '' + const userId = data?.userId || data?.nickname || '' // 결과 값 추출 - const result = data.result || data.raw || {} + const result = data?.result || data?.raw || {} + + console.log('[SystemCommandMessage] displayText:', displayText) return ( From 93e246266e042430fc2dc648d2d9c7cab2add2d0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:31:30 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[DEBUG]=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/freetalk/hooks/useChatWebSocket.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 685c156..a70c965 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -166,6 +166,7 @@ export function useChatWebSocket(roomId, userId) { onSystemCommand: (data) => { console.log('[useChatWebSocket] System command:', data) + console.log('[useChatWebSocket] data.content:', data.content) const commandData = data.data || {} // 백엔드 응답 구조: // - content: 포맷팅된 메시지 (최상위) @@ -186,6 +187,8 @@ export function useChatWebSocket(roomId, userId) { raw: commandData, }, } + console.log('[useChatWebSocket] Adding command message:', commandMessage) + console.log('[useChatWebSocket] displayText value:', commandMessage.data.displayText) setMessages((prev) => [...prev, commandMessage]) }, From f6f276fe45916ab6f7b83f3f786c4e99079b1372 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:32:34 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[DEBUG]=20ChatRoomPage=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/freetalk/pages/ChatRoomPage.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 2a220d6..2577013 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -377,6 +377,9 @@ const ChatRoomPage = () => { ) : ( messages.map((message) => { + // 디버깅 + console.log('[ChatRoomPage] Rendering message:', message.messageType, message) + // 투표 메시지 if (message.messageType === MessageType.POLL_CREATE) { return ( From 084201f59d92e4e3da541bdbfc2e0a70f2cb3d3d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:33:54 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[DEBUG]=20SYSTEM=5FCOMMAND=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B2=B4=ED=81=AC=20=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/freetalk/pages/ChatRoomPage.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 2577013..a5320d3 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -142,6 +142,7 @@ const ChatRoomPage = () => { } useEffect(() => { + console.log('[ChatRoomPage] messages updated:', messages.length, messages.map(m => m.messageType)) scrollToBottom() }, [messages]) @@ -378,7 +379,7 @@ const ChatRoomPage = () => { ) : ( messages.map((message) => { // 디버깅 - console.log('[ChatRoomPage] Rendering message:', message.messageType, message) + console.log('[ChatRoomPage] Rendering message:', message.messageType, 'expected:', MessageType.SYSTEM_COMMAND, 'match:', message.messageType === MessageType.SYSTEM_COMMAND) // 투표 메시지 if (message.messageType === MessageType.POLL_CREATE) { From 42146a18c37e2c1d7e57c743ce17da8a8bf559f9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:37:43 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[FIX]=20ChatRoomModal=EC=97=90=20SYSTEM=5FC?= =?UTF-8?q?OMMAND=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/freetalk/components/ChatRoomModal.jsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index c62ca79..1856abc 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -35,6 +35,8 @@ import {useAuth} from '../../../contexts/AuthContext' import {useThemeMode} from '../../../contexts/ThemeContext' import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' import {useChatWebSocket} from '../hooks/useChatWebSocket' +import SystemCommandMessage from './SystemCommandMessage' +import {MessageType} from '../types/chatCommandTypes' const ChatRoomModal = ({open, onClose, room, onLeave}) => { const theme = useTheme() @@ -456,7 +458,15 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { ) : ( - messages.map((message) => ( + messages.map((message) => { + // 시스템 명령어 메시지 (SYSTEM_COMMAND) + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { + return ( + + ) + } + + return ( { )} - )) + )}) )}
From 9aff72906b5998d2ba01a3b73d15fdaddc3c6c71 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:40:12 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[CLEANUP]=20=EB=94=94=EB=B2=84=EA=B9=85=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/freetalk/components/SystemCommandMessage.jsx | 7 +------ src/domains/freetalk/hooks/useChatWebSocket.js | 8 -------- src/domains/freetalk/pages/ChatRoomPage.jsx | 4 ---- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx index 8781e56..253382f 100644 --- a/src/domains/freetalk/components/SystemCommandMessage.jsx +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -18,10 +18,7 @@ const SystemCommandMessage = ({ data }) => { const { mode } = useThemeMode() const isDark = mode === 'dark' - // 디버깅 - console.log('[SystemCommandMessage] received data:', data) - - // 명령어 타입 추출 (여러 가능한 필드 확인) + // 명령어 타입 추출 const commandType = data?.commandType || data?.type || data?.raw?.type || 'help' const config = SystemCommandConfig[commandType] || SystemCommandConfig.help const { icon, color, bgColor } = config @@ -33,8 +30,6 @@ const SystemCommandMessage = ({ data }) => { // 결과 값 추출 const result = data?.result || data?.raw || {} - console.log('[SystemCommandMessage] displayText:', displayText) - return ( { - console.log('[useChatWebSocket] System command:', data) - console.log('[useChatWebSocket] data.content:', data.content) const commandData = data.data || {} - // 백엔드 응답 구조: - // - content: 포맷팅된 메시지 (최상위) - // - data.type: 명령어 타입 (dice, coin, etc.) - // - data.result: 결과 값 const commandMessage = { id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, messageType: 'SYSTEM_COMMAND', @@ -187,8 +181,6 @@ export function useChatWebSocket(roomId, userId) { raw: commandData, }, } - console.log('[useChatWebSocket] Adding command message:', commandMessage) - console.log('[useChatWebSocket] displayText value:', commandMessage.data.displayText) setMessages((prev) => [...prev, commandMessage]) }, diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index a5320d3..2a220d6 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -142,7 +142,6 @@ const ChatRoomPage = () => { } useEffect(() => { - console.log('[ChatRoomPage] messages updated:', messages.length, messages.map(m => m.messageType)) scrollToBottom() }, [messages]) @@ -378,9 +377,6 @@ const ChatRoomPage = () => { ) : ( messages.map((message) => { - // 디버깅 - console.log('[ChatRoomPage] Rendering message:', message.messageType, 'expected:', MessageType.SYSTEM_COMMAND, 'match:', message.messageType === MessageType.SYSTEM_COMMAND) - // 투표 메시지 if (message.messageType === MessageType.POLL_CREATE) { return (