192 lines
7.0 KiB
TypeScript
192 lines
7.0 KiB
TypeScript
import { useState, useRef, useEffect, memo, useCallback } from 'react'
|
||
import AudioPlayer from '@/components/media/AudioPlayer'
|
||
import {
|
||
ErrorReportButton,
|
||
TestHeader,
|
||
ConfidenceLevel
|
||
} from '@/components/review/shared'
|
||
import { ConfidenceTestProps } from '@/types/review'
|
||
|
||
interface FlipMemoryTestProps extends ConfidenceTestProps {}
|
||
|
||
const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||
cardData,
|
||
onConfidenceSubmit,
|
||
onReportError,
|
||
disabled = false
|
||
}) => {
|
||
const [isFlipped, setIsFlipped] = useState(false)
|
||
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
|
||
const [cardHeight, setCardHeight] = useState<number>(400)
|
||
const frontRef = useRef<HTMLDivElement>(null)
|
||
const backRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 判斷是否已答題(選擇了信心等級)
|
||
const hasAnswered = selectedConfidence !== null
|
||
|
||
useEffect(() => {
|
||
const updateCardHeight = () => {
|
||
if (backRef.current) {
|
||
const backHeight = backRef.current.scrollHeight
|
||
|
||
// 響應式最小高度設定
|
||
const minHeightByScreen = window.innerWidth <= 480 ? 300 :
|
||
window.innerWidth <= 768 ? 350 : 400
|
||
|
||
// 以背面內容高度為準,不設最大高度限制
|
||
const finalHeight = Math.max(minHeightByScreen, backHeight)
|
||
setCardHeight(finalHeight)
|
||
}
|
||
}
|
||
|
||
// 延遲執行以確保內容已渲染
|
||
const timer = setTimeout(updateCardHeight, 100)
|
||
|
||
window.addEventListener('resize', updateCardHeight)
|
||
return () => {
|
||
clearTimeout(timer)
|
||
window.removeEventListener('resize', updateCardHeight)
|
||
}
|
||
}, [cardData.word, cardData.definition, cardData.example, cardData.synonyms])
|
||
|
||
const handleFlip = useCallback(() => {
|
||
if (!disabled) setIsFlipped(!isFlipped)
|
||
}, [disabled, isFlipped])
|
||
|
||
const handleConfidenceSelect = useCallback((level: number) => {
|
||
if (disabled || hasAnswered) return
|
||
setSelectedConfidence(level)
|
||
onConfidenceSubmit(level)
|
||
}, [disabled, hasAnswered, onConfidenceSubmit])
|
||
|
||
return (
|
||
<div className="relative">
|
||
<div className="flex justify-end mb-2">
|
||
<ErrorReportButton onClick={onReportError} />
|
||
</div>
|
||
|
||
{/* 翻卡容器 */}
|
||
<div
|
||
className={`relative w-full ${disabled ? 'pointer-events-none opacity-75' : 'cursor-pointer'}`}
|
||
onClick={handleFlip}
|
||
style={{
|
||
perspective: '1000px',
|
||
height: `${cardHeight}px`,
|
||
minHeight: '400px',
|
||
transition: 'height 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '100%',
|
||
transformStyle: 'preserve-3d',
|
||
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'
|
||
}}
|
||
>
|
||
{/* 正面 */}
|
||
<div
|
||
ref={frontRef}
|
||
className="absolute w-full h-full bg-white rounded-xl shadow-lg hover:shadow-xl"
|
||
style={{ backfaceVisibility: 'hidden' }}
|
||
>
|
||
<div className="p-8 h-full">
|
||
<TestHeader
|
||
title="翻卡記憶"
|
||
cefr={cardData.cefr}
|
||
/>
|
||
|
||
<div className="space-y-4">
|
||
<p className="text-lg text-gray-700 mb-6 text-left">
|
||
點擊卡片翻面,根據你對單字的熟悉程度進行自我評估:
|
||
</p>
|
||
|
||
<div className="flex-1 flex items-center justify-center mt-6">
|
||
<div className="bg-gray-50 rounded-lg p-8 w-full text-center">
|
||
<h3 className="text-4xl font-bold text-gray-900 mb-6">{cardData.word}</h3>
|
||
<div className="flex items-center justify-center gap-3">
|
||
{cardData.pronunciation && (
|
||
<span className="text-lg text-gray-500">{cardData.pronunciation}</span>
|
||
)}
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<AudioPlayer text={cardData.word} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 背面 */}
|
||
<div
|
||
ref={backRef}
|
||
className="absolute w-full h-full bg-white rounded-xl shadow-lg"
|
||
style={{
|
||
backfaceVisibility: 'hidden',
|
||
transform: 'rotateY(180deg)'
|
||
}}
|
||
>
|
||
<div className="p-8 h-full">
|
||
<TestHeader
|
||
title="翻卡記憶"
|
||
cefr={cardData.cefr}
|
||
/>
|
||
|
||
<div className="space-y-4 pb-6">
|
||
{/* 定義區塊 */}
|
||
<div className="bg-gray-50 rounded-lg p-4">
|
||
<h3 className="font-semibold text-gray-900 mb-2 text-left">定義</h3>
|
||
<p className="text-gray-700 text-left">{cardData.definition}</p>
|
||
</div>
|
||
|
||
{/* 例句區塊 */}
|
||
<div className="bg-gray-50 rounded-lg p-4">
|
||
<h3 className="font-semibold text-gray-900 mb-2 text-left">例句</h3>
|
||
<div className="relative">
|
||
<p className="text-gray-700 italic mb-2 text-left pr-12">{cardData.example}</p>
|
||
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
|
||
<AudioPlayer text={cardData.example} />
|
||
</div>
|
||
</div>
|
||
<p className="text-gray-600 text-sm text-left">{cardData.exampleTranslation}</p>
|
||
</div>
|
||
|
||
{/* 同義詞區塊 */}
|
||
{cardData.synonyms && cardData.synonyms.length > 0 && (
|
||
<div className="bg-gray-50 rounded-lg p-4">
|
||
<h3 className="font-semibold text-gray-900 mb-2 text-left">同義詞</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{cardData.synonyms.map((synonym, index) => (
|
||
<span
|
||
key={index}
|
||
className="bg-white text-gray-700 px-3 py-1 rounded-full text-sm border border-gray-200"
|
||
>
|
||
{synonym}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 信心等級評估區 - 使用新元件 */}
|
||
<div className="mt-6">
|
||
<ConfidenceLevel
|
||
selectedLevel={selectedConfidence}
|
||
onSelect={handleConfidenceSelect}
|
||
disabled={disabled || hasAnswered}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const FlipMemoryTest = memo(FlipMemoryTestComponent)
|
||
FlipMemoryTest.displayName = 'FlipMemoryTest' |