From 3cfeb1520b0b1f1108d1dc8a62e3c38932401d5f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 11:55:03 +0900 Subject: [PATCH] =?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', + }, +}