Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/domains/freetalk/components/CommandAutocomplete.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Paper
elevation={8}
sx={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
mb: 1,
maxHeight: 300,
overflow: 'auto',
bgcolor: isDark ? '#1e293b' : '#ffffff',
border: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid #e0e0e0',
borderRadius: 2,
zIndex: 1000,
}}
>
<Box sx={{ p: 1.5, borderBottom: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid #e0e0e0' }}>
<Typography variant="caption" color="text.secondary" fontWeight={600}>
사용 가능한 명령어 ({filteredCommands.length})
</Typography>
</Box>

<List sx={{ p: 0 }}>
{filteredCommands.map((cmd, index) => (
<ListItem key={cmd.command} disablePadding>
<ListItemButton
selected={index === selectedIndex}
onClick={() => 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)',
},
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="body2"
fontWeight={600}
sx={{
fontFamily: 'monospace',
color: isDark ? '#facc15' : '#f59e0b',
}}
>
{cmd.command}
</Typography>
<Typography variant="caption" color="text.secondary">
{cmd.usage}
</Typography>
</Box>
}
secondary={
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{cmd.description}
</Typography>
}
/>
</ListItemButton>
</ListItem>
))}
</List>

<Box
sx={{
p: 1,
bgcolor: isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5',
borderTop: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid #e0e0e0',
}}
>
<Typography variant="caption" color="text.secondary">
화살표로 선택, Enter로 입력, Esc로 닫기
</Typography>
</Box>
</Paper>
)
}

export default CommandAutocomplete
175 changes: 175 additions & 0 deletions src/domains/freetalk/components/PollCard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card
elevation={2}
sx={{
maxWidth: 500,
bgcolor: isDark ? '#1e293b' : '#ffffff',
border: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid #e0e0e0',
borderRadius: 2,
}}
>
<CardContent sx={{ p: 2 }}>
{/* 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<VoteIcon sx={{ color: isDark ? '#facc15' : '#f59e0b' }} />
<Typography variant="subtitle2" fontWeight={600}>
투표
</Typography>
{poll.isActive ? (
<Chip
label="진행 중"
size="small"
color="success"
sx={{ ml: 'auto', height: 20, fontSize: '0.7rem' }}
/>
) : (
<Chip
label="종료됨"
size="small"
color="default"
sx={{ ml: 'auto', height: 20, fontSize: '0.7rem' }}
/>
)}
</Box>

{/* 질문 */}
<Typography variant="body1" fontWeight={600} sx={{ mb: 2 }}>
{poll.question}
</Typography>

{/* 옵션 목록 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
{poll.options.map((option, index) => {
const percentage = percentages[index]
const isSelected = selectedOption === option.optionId
const userVoted = option.voters.includes(currentUserId)

return (
<Box key={option.optionId}>
{showResults ? (
// 결과 보기 모드
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" fontWeight={userVoted ? 600 : 400}>
{option.text}
</Typography>
{userVoted && <CheckIcon sx={{ fontSize: 16, color: 'success.main' }} />}
</Box>
<Typography variant="caption" color="text.secondary">
{option.voteCount}표 ({percentage}%)
</Typography>
</Box>
<PollResultBar percentage={percentage} isUserVoted={userVoted} />
</Box>
) : (
// 투표하기 모드
<Button
fullWidth
variant={isSelected ? 'contained' : 'outlined'}
onClick={() => handleVote(option.optionId)}
disabled={!poll.isActive}
sx={{
justifyContent: 'flex-start',
textTransform: 'none',
py: 1.5,
borderRadius: 2,
bgcolor: isSelected ? (isDark ? '#facc15' : '#fee500') : 'transparent',
color: isSelected ? '#1c1917' : 'inherit',
borderColor: isDark ? 'rgba(255,255,255,0.2)' : '#e0e0e0',
'&:hover': {
bgcolor: isSelected
? isDark
? '#fbbf24'
: '#fde047'
: isDark
? 'rgba(255,255,255,0.05)'
: 'rgba(0,0,0,0.04)',
},
}}
>
<Typography variant="body2">{option.text}</Typography>
</Button>
)}
</Box>
)
})}
</Box>

{/* 하단 정보 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pt: 1.5,
borderTop: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid #e0e0e0',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PersonIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography variant="caption" color="text.secondary">
총 {totalVotes}명 참여
</Typography>
</Box>

{isCreator && poll.isActive && (
<IconButton size="small" onClick={handleEndPoll} color="error" title="투표 종료">
<CancelIcon fontSize="small" />
</IconButton>
)}
</Box>

{/* 생성자 정보 */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
생성자: {poll.creatorId}
</Typography>
</CardContent>
</Card>
)
}

export default PollCard
57 changes: 57 additions & 0 deletions src/domains/freetalk/components/PollResultBar.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={{ position: 'relative', width: '100%' }}>
<LinearProgress
variant="determinate"
value={percentage}
sx={{
height: 8,
borderRadius: 1,
bgcolor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
'& .MuiLinearProgress-bar': {
borderRadius: 1,
bgcolor: isUserVoted
? isDark
? '#facc15'
: '#fee500'
: isDark
? 'rgba(250, 204, 21, 0.5)'
: 'rgba(254, 229, 0, 0.5)',
transition: 'transform 0.4s ease-in-out',
},
}}
/>
{isUserVoted && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 1,
border: `2px solid ${isDark ? '#fbbf24' : '#fde047'}`,
pointerEvents: 'none',
}}
/>
)}
</Box>
)
}

export default PollResultBar
Loading