dramaling-vocab-learning/frontend/components/review/shared/AnswerActions.tsx

312 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'