312 lines
9.0 KiB
TypeScript
312 lines
9.0 KiB
TypeScript
import React, { memo, useState } from 'react'
|
||
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||
|
||
/**
|
||
* 答題動作元件集合
|
||
* 提供標準化的使用者互動介面,支援各種答題類型
|
||
*/
|
||
|
||
// 選擇題選項元件
|
||
interface ChoiceOptionProps {
|
||
option: string
|
||
index: number
|
||
isSelected?: boolean
|
||
isCorrect?: boolean
|
||
isIncorrect?: boolean
|
||
showResult?: boolean
|
||
onSelect: (option: string) => void
|
||
disabled?: boolean
|
||
}
|
||
|
||
export const ChoiceOption: React.FC<ChoiceOptionProps> = memo(({
|
||
option,
|
||
index,
|
||
isSelected = false,
|
||
isCorrect = false,
|
||
isIncorrect = false,
|
||
showResult = false,
|
||
onSelect,
|
||
disabled = false
|
||
}) => {
|
||
const getOptionStyles = () => {
|
||
if (showResult) {
|
||
if (isCorrect) {
|
||
return 'border-green-500 bg-green-50 text-green-700'
|
||
}
|
||
if (isIncorrect && isSelected) {
|
||
return 'border-red-500 bg-red-50 text-red-700'
|
||
}
|
||
return 'border-gray-200 bg-gray-50 text-gray-500'
|
||
}
|
||
|
||
if (isSelected) {
|
||
return 'border-blue-500 bg-blue-50 text-blue-700'
|
||
}
|
||
|
||
return 'border-gray-200 hover:border-blue-300 hover:bg-blue-50'
|
||
}
|
||
|
||
return (
|
||
<button
|
||
onClick={() => !disabled && !showResult && onSelect(option)}
|
||
disabled={disabled || showResult}
|
||
className={`p-4 text-center rounded-lg border-2 transition-all ${getOptionStyles()}`}
|
||
aria-label={`選項 ${index + 1}: ${option}`}
|
||
>
|
||
<div className="text-lg font-medium">{option}</div>
|
||
</button>
|
||
)
|
||
})
|
||
|
||
ChoiceOption.displayName = 'ChoiceOption'
|
||
|
||
// 選擇題選項網格
|
||
interface ChoiceGridProps {
|
||
options: string[]
|
||
selectedOption?: string | null
|
||
correctAnswer?: string
|
||
showResult?: boolean
|
||
onSelect: (option: string) => void
|
||
disabled?: boolean
|
||
className?: string
|
||
}
|
||
|
||
export const ChoiceGrid: React.FC<ChoiceGridProps> = memo(({
|
||
options,
|
||
selectedOption,
|
||
correctAnswer,
|
||
showResult = false,
|
||
onSelect,
|
||
disabled = false,
|
||
className = ''
|
||
}) => {
|
||
return (
|
||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-3 ${className}`}>
|
||
{options.map((option, index) => (
|
||
<ChoiceOption
|
||
key={`${option}-${index}`}
|
||
option={option}
|
||
index={index}
|
||
isSelected={selectedOption === option}
|
||
isCorrect={showResult && option === correctAnswer}
|
||
isIncorrect={showResult && option !== correctAnswer}
|
||
showResult={showResult}
|
||
onSelect={onSelect}
|
||
disabled={disabled}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
ChoiceGrid.displayName = 'ChoiceGrid'
|
||
|
||
// 文字輸入元件
|
||
interface TextInputProps {
|
||
value: string
|
||
onChange: (value: string) => void
|
||
onSubmit: (value: string) => void
|
||
placeholder?: string
|
||
disabled?: boolean
|
||
showResult?: boolean
|
||
isCorrect?: boolean
|
||
correctAnswer?: string
|
||
}
|
||
|
||
export const TextInput: React.FC<TextInputProps> = memo(({
|
||
value,
|
||
onChange,
|
||
onSubmit,
|
||
placeholder = '請輸入答案...',
|
||
disabled = false,
|
||
showResult = false,
|
||
isCorrect = false,
|
||
correctAnswer = ''
|
||
}) => {
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !disabled && !showResult && value.trim()) {
|
||
onSubmit(value.trim())
|
||
}
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
if (!disabled && !showResult && value.trim()) {
|
||
onSubmit(value.trim())
|
||
}
|
||
}
|
||
|
||
const getInputStyles = () => {
|
||
if (showResult) {
|
||
return isCorrect
|
||
? 'border-green-500 bg-green-50'
|
||
: 'border-red-500 bg-red-50'
|
||
}
|
||
return 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={placeholder}
|
||
disabled={disabled || showResult}
|
||
className={`w-full p-3 border rounded-lg text-lg ${getInputStyles()}`}
|
||
autoFocus
|
||
/>
|
||
{!showResult && (
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={disabled || !value.trim()}
|
||
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-4 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
提交
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{showResult && !isCorrect && correctAnswer && (
|
||
<div className="text-sm text-gray-600">
|
||
正確答案:<span className="font-medium text-green-600">{correctAnswer}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
TextInput.displayName = 'TextInput'
|
||
|
||
// 信心度按鈕組
|
||
interface ConfidenceLevelProps {
|
||
selectedLevel?: number | null
|
||
onSelect: (level: number) => void
|
||
disabled?: boolean
|
||
}
|
||
|
||
export const ConfidenceLevel: React.FC<ConfidenceLevelProps> = memo(({
|
||
selectedLevel,
|
||
onSelect,
|
||
disabled = false
|
||
}) => {
|
||
const levels = [
|
||
{ level: 1, label: '完全不熟', color: 'bg-red-500', hoverColor: 'hover:bg-red-600' },
|
||
{ level: 2, label: '有點印象', color: 'bg-orange-500', hoverColor: 'hover:bg-orange-600' },
|
||
{ level: 3, label: '還算熟悉', color: 'bg-yellow-500', hoverColor: 'hover:bg-yellow-600' },
|
||
{ level: 4, label: '很熟悉', color: 'bg-blue-500', hoverColor: 'hover:bg-blue-600' },
|
||
{ level: 5, label: '完全掌握', color: 'bg-green-500', hoverColor: 'hover:bg-green-600' }
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h3 className="text-lg font-semibold text-gray-900">請評估你對這個單字的熟悉程度:</h3>
|
||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-3">
|
||
{levels.map(({ level, label, color, hoverColor }) => (
|
||
<button
|
||
key={level}
|
||
onClick={() => !disabled && onSelect(level)}
|
||
disabled={disabled}
|
||
className={`p-3 rounded-lg text-white font-medium transition-all ${
|
||
selectedLevel === level
|
||
? `${color} ring-4 ring-opacity-50 ring-offset-2 ring-current`
|
||
: `${color} ${hoverColor} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`
|
||
}`}
|
||
>
|
||
<div className="text-sm">{level}</div>
|
||
<div className="text-xs">{label}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
|
||
ConfidenceLevel.displayName = 'ConfidenceLevel'
|
||
|
||
// 錄音控制元件
|
||
interface RecordingControlProps {
|
||
isRecording: boolean
|
||
hasRecording: boolean
|
||
onStartRecording: () => void
|
||
onStopRecording: () => void
|
||
onPlayback: () => void
|
||
onSubmit: () => void
|
||
disabled?: boolean
|
||
}
|
||
|
||
export const RecordingControl: React.FC<RecordingControlProps> = memo(({
|
||
isRecording,
|
||
hasRecording,
|
||
onStartRecording,
|
||
onStopRecording,
|
||
onPlayback,
|
||
onSubmit,
|
||
disabled = false
|
||
}) => {
|
||
const [isPlayingRecording, setIsPlayingRecording] = useState(false)
|
||
|
||
const handlePlaybackToggle = () => {
|
||
if (isPlayingRecording) {
|
||
setIsPlayingRecording(false)
|
||
} else {
|
||
setIsPlayingRecording(true)
|
||
onPlayback()
|
||
// 模擬播放結束
|
||
setTimeout(() => setIsPlayingRecording(false), 3000)
|
||
}
|
||
}
|
||
return (
|
||
<div className="flex flex-col items-center space-y-4">
|
||
{/* 錄音按鈕 */}
|
||
<button
|
||
onClick={isRecording ? onStopRecording : onStartRecording}
|
||
disabled={disabled}
|
||
className={`w-16 h-16 rounded-full flex items-center justify-center text-white font-bold transition-all ${
|
||
isRecording
|
||
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
||
: 'bg-blue-500 hover:bg-blue-600'
|
||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||
>
|
||
{isRecording ? '⏹️' : '🎤'}
|
||
</button>
|
||
|
||
{/* 狀態文字 */}
|
||
<div className="text-center">
|
||
{isRecording ? (
|
||
<p className="text-red-600 font-medium">錄音中... 點擊停止</p>
|
||
) : hasRecording ? (
|
||
<p className="text-green-600 font-medium">錄音完成</p>
|
||
) : (
|
||
<p className="text-gray-600">點擊開始錄音</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 控制按鈕 */}
|
||
{hasRecording && !isRecording && (
|
||
<div className="flex space-x-3">
|
||
<div className="flex items-center gap-2">
|
||
<BluePlayButton
|
||
isPlaying={isPlayingRecording}
|
||
onToggle={handlePlaybackToggle}
|
||
disabled={disabled}
|
||
size="sm"
|
||
title="播放錄音"
|
||
/>
|
||
<span className="text-gray-700 text-sm">播放錄音</span>
|
||
</div>
|
||
<button
|
||
onClick={onSubmit}
|
||
disabled={disabled}
|
||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||
>
|
||
提交錄音
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
RecordingControl.displayName = 'RecordingControl' |